@jasonshimmy/vite-plugin-cer-app 0.1.2 → 0.1.4

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 (91) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/commits.txt +1 -1
  3. package/cypress.config.ts +16 -0
  4. package/dist/cli/create/index.js +1 -1
  5. package/dist/cli/create/index.js.map +1 -1
  6. package/dist/plugin/build-ssg.d.ts +7 -0
  7. package/dist/plugin/build-ssg.d.ts.map +1 -1
  8. package/dist/plugin/build-ssg.js +2 -1
  9. package/dist/plugin/build-ssg.js.map +1 -1
  10. package/dist/plugin/build-ssr.d.ts.map +1 -1
  11. package/dist/plugin/build-ssr.js +26 -6
  12. package/dist/plugin/build-ssr.js.map +1 -1
  13. package/dist/runtime/composables/index.d.ts +1 -1
  14. package/dist/runtime/composables/index.d.ts.map +1 -1
  15. package/dist/runtime/composables/index.js +1 -1
  16. package/dist/runtime/composables/index.js.map +1 -1
  17. package/dist/runtime/composables/use-head.d.ts.map +1 -1
  18. package/dist/runtime/composables/use-head.js +12 -8
  19. package/dist/runtime/composables/use-head.js.map +1 -1
  20. package/dist/runtime/entry-server-template.d.ts +1 -1
  21. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  22. package/dist/runtime/entry-server-template.js +14 -4
  23. package/dist/runtime/entry-server-template.js.map +1 -1
  24. package/docs/cli.md +2 -0
  25. package/docs/components.md +57 -0
  26. package/docs/composables.md +9 -2
  27. package/docs/data-loading.md +45 -1
  28. package/docs/getting-started.md +71 -6
  29. package/docs/head-management.md +6 -0
  30. package/docs/plugins.md +25 -0
  31. package/docs/routing.md +48 -6
  32. package/e2e/cypress/e2e/api.cy.ts +81 -0
  33. package/e2e/cypress/e2e/data.cy.ts +111 -0
  34. package/e2e/cypress/e2e/fouc.cy.ts +65 -0
  35. package/e2e/cypress/e2e/head.cy.ts +89 -0
  36. package/e2e/cypress/e2e/interactive.cy.ts +122 -0
  37. package/e2e/cypress/e2e/routes.cy.ts +128 -0
  38. package/e2e/cypress/support/commands.ts +60 -0
  39. package/e2e/cypress/support/e2e.ts +10 -0
  40. package/{src/runtime/app-template.ts → e2e/kitchen-sink/app/app.ts} +43 -49
  41. package/e2e/kitchen-sink/app/components/ks-badge.ts +8 -0
  42. package/e2e/kitchen-sink/app/composables/useKsCounter.ts +9 -0
  43. package/e2e/kitchen-sink/app/error.ts +13 -0
  44. package/e2e/kitchen-sink/app/layouts/default.ts +21 -0
  45. package/e2e/kitchen-sink/app/layouts/minimal.ts +7 -0
  46. package/e2e/kitchen-sink/app/loading.ts +9 -0
  47. package/e2e/kitchen-sink/app/middleware/auth.ts +13 -0
  48. package/e2e/kitchen-sink/app/pages/(auth)/login.ts +13 -0
  49. package/e2e/kitchen-sink/app/pages/(auth)/protected.ts +20 -0
  50. package/e2e/kitchen-sink/app/pages/404.ts +9 -0
  51. package/e2e/kitchen-sink/app/pages/about.ts +17 -0
  52. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +54 -0
  53. package/e2e/kitchen-sink/app/pages/blog/index.ts +46 -0
  54. package/e2e/kitchen-sink/app/pages/counter.ts +17 -0
  55. package/e2e/kitchen-sink/app/pages/head.ts +20 -0
  56. package/e2e/kitchen-sink/app/pages/index.ts +27 -0
  57. package/e2e/kitchen-sink/app/pages/items/[id].ts +20 -0
  58. package/e2e/kitchen-sink/app/plugins/01.setup.ts +7 -0
  59. package/e2e/kitchen-sink/cer-auto-imports.d.ts +50 -0
  60. package/e2e/kitchen-sink/cer-env.d.ts +36 -0
  61. package/e2e/kitchen-sink/cer-tsconfig.json +30 -0
  62. package/e2e/kitchen-sink/cer.config.ts +6 -0
  63. package/e2e/kitchen-sink/index.html +12 -0
  64. package/e2e/kitchen-sink/server/api/health.ts +3 -0
  65. package/e2e/kitchen-sink/server/api/posts/[slug].ts +11 -0
  66. package/e2e/kitchen-sink/server/api/posts/index.ts +5 -0
  67. package/e2e/kitchen-sink/server/data/posts.ts +21 -0
  68. package/e2e/scripts/clean.mjs +8 -0
  69. package/package.json +19 -2
  70. package/src/__tests__/plugin/build-ssg-render.test.ts +110 -0
  71. package/src/__tests__/plugin/build-ssg.test.ts +47 -1
  72. package/src/__tests__/plugin/build-ssr.test.ts +93 -1
  73. package/src/__tests__/plugin/dev-server.test.ts +493 -0
  74. package/src/__tests__/plugin/scanner.test.ts +15 -1
  75. package/src/__tests__/plugin/transforms/auto-import.test.ts +63 -0
  76. package/src/cli/create/index.ts +1 -1
  77. package/src/cli/create/templates/spa/app/app.ts.tpl +23 -3
  78. package/src/cli/create/templates/ssg/app/app.ts.tpl +27 -3
  79. package/src/cli/create/templates/ssg/app/pages/index.ts.tpl +0 -9
  80. package/src/cli/create/templates/ssr/app/app.ts.tpl +27 -3
  81. package/src/plugin/build-ssg.ts +2 -1
  82. package/src/plugin/build-ssr.ts +26 -6
  83. package/src/runtime/composables/index.ts +1 -1
  84. package/src/runtime/composables/use-head.ts +12 -8
  85. package/src/runtime/entry-server-template.ts +14 -4
  86. package/vitest.config.ts +5 -1
  87. package/VITE_PLUGIN_FRAMEWORK_PLAN.md +0 -594
  88. package/dist/runtime/app-template.d.ts +0 -10
  89. package/dist/runtime/app-template.d.ts.map +0 -1
  90. package/dist/runtime/app-template.js +0 -149
  91. package/dist/runtime/app-template.js.map +0 -1
@@ -227,3 +227,66 @@ describe('autoImportTransform — framework composable injection', () => {
227
227
  expect(result).toContain('usePageData')
228
228
  })
229
229
  })
230
+
231
+ // ─── Composable import injection ─────────────────────────────────────────────
232
+
233
+ describe('autoImportTransform — composable import injection', () => {
234
+ const COMPOSABLES_PKG = `'virtual:cer-composables'`
235
+
236
+ it('injects composable import when a registered composable is used', () => {
237
+ const composableExports = new Map([['useTheme', '/project/app/composables/useTheme.ts']])
238
+ const code = "component('page-x', () => { const t = useTheme(); return html`<div></div>` })"
239
+ const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })!
240
+ expect(result).toContain(`from ${COMPOSABLES_PKG}`)
241
+ expect(result).toContain('useTheme')
242
+ })
243
+
244
+ it('does not inject composable import when composable is not used in file', () => {
245
+ const composableExports = new Map([['useTheme', '/project/app/composables/useTheme.ts']])
246
+ const code = "component('page-x', () => html`<h1>Hello</h1>`)"
247
+ const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })
248
+ expect(result === null || !result!.includes('virtual:cer-composables')).toBe(true)
249
+ })
250
+
251
+ it('does not inject when already imported from virtual:cer-composables (single quotes)', () => {
252
+ const composableExports = new Map([['useTheme', '/project/app/composables/useTheme.ts']])
253
+ const code = `import { useTheme } from 'virtual:cer-composables'\ncomponent('x', () => { useTheme(); return html\`\` })`
254
+ const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })
255
+ const count = (result ?? code).split(`from ${COMPOSABLES_PKG}`).length - 1
256
+ expect(count).toBe(1)
257
+ })
258
+
259
+ it('does not inject when already imported from virtual:cer-composables (double quotes)', () => {
260
+ const composableExports = new Map([['useTheme', '/project/app/composables/useTheme.ts']])
261
+ const code = `import { useTheme } from "virtual:cer-composables"\ncomponent('x', () => { useTheme(); return html\`\` })`
262
+ const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })
263
+ const count = (result ?? code).split('virtual:cer-composables').length - 1
264
+ expect(count).toBe(1)
265
+ })
266
+
267
+ it('injects all used composables in a single import statement', () => {
268
+ const composableExports = new Map([
269
+ ['useTheme', '/project/app/composables/useTheme.ts'],
270
+ ['useAuth', '/project/app/composables/useAuth.ts'],
271
+ ])
272
+ const code = "component('page-x', () => { useTheme(); useAuth(); return html`<div></div>` })"
273
+ const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })!
274
+ expect(result).toContain('useTheme')
275
+ expect(result).toContain('useAuth')
276
+ const count = result.split(`from ${COMPOSABLES_PKG}`).length - 1
277
+ expect(count).toBe(1)
278
+ })
279
+
280
+ it('returns null when composableExports is empty and no other identifiers used', () => {
281
+ const composableExports = new Map<string, string>()
282
+ const code = 'const x = 1'
283
+ const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })
284
+ expect(result).toBeNull()
285
+ })
286
+
287
+ it('returns null when composableExports is undefined and no other identifiers used', () => {
288
+ const code = 'const x = 1'
289
+ const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir })
290
+ expect(result).toBeNull()
291
+ })
292
+ })
@@ -218,7 +218,7 @@ async function generateInlineTemplate(
218
218
  // app/app.ts
219
219
  await writeFile(
220
220
  join(targetDir, 'app/app.ts'),
221
- `import '@jasonshimmy/custom-elements-runtime/css'\nimport 'virtual:cer-components'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport { hasLoading, loadingTag } from 'virtual:cer-loading'\nimport { hasError, errorTag } from 'virtual:cer-error'\nimport { component, ref, useOnConnected, useOnDisconnected, registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'\nimport { createDOMJITCSS } from '@jasonshimmy/custom-elements-runtime/dom-jit-css'\n\nregisterBuiltinComponents()\nenableJITCSS()\n\nconst router = initRouter({ routes })\n\nconst isNavigating = ref(false)\nconst currentError = ref(null)\n;(globalThis as any).resetError = () => { currentError.value = null; router.replace(router.getCurrent().path) }\nconst _push = router.push.bind(router)\nconst _replace = router.replace.bind(router)\nrouter.push = async (path) => { isNavigating.value = true; currentError.value = null; try { await _push(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\nrouter.replace = async (path) => { isNavigating.value = true; currentError.value = null; try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\n\ncomponent('cer-layout-view', () => {\n const current = ref(router.getCurrent())\n let unsub: (() => void) | undefined\n useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })\n useOnDisconnected(() => { unsub?.(); unsub = undefined })\n if (currentError.value !== null) {\n if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }\n return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: [String(currentError.value)] }\n }\n if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }\n const matched = router.matchRoute(current.value.path)\n const layoutName = (matched?.route as any)?.meta?.layout ?? 'default'\n const layoutTag = (layouts as Record<string, string>)[layoutName]\n const routerView = { tag: 'router-view', props: {}, children: [] }\n return layoutTag ? { tag: layoutTag, props: {}, children: [routerView] } : routerView\n})\n\nfor (const plugin of plugins ?? []) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({ router, provide: (key, value) => { (globalThis as any)[key] = value }, config: {} })\n }\n}\n\nif (typeof window !== 'undefined') {\n await router.replace(window.location.pathname + window.location.search + window.location.hash)\n createDOMJITCSS().mount()\n}\n\nexport { router }\n`,
221
+ `import '@jasonshimmy/custom-elements-runtime/css'\nimport 'virtual:cer-components'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport { hasLoading, loadingTag } from 'virtual:cer-loading'\nimport { hasError, errorTag } from 'virtual:cer-error'\nimport { component, ref, provide, useOnConnected, useOnDisconnected, registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'\nimport { createDOMJITCSS } from '@jasonshimmy/custom-elements-runtime/dom-jit-css'\n\nregisterBuiltinComponents()\nenableJITCSS()\n\nconst router = initRouter({ routes })\n\nconst isNavigating = ref(false)\nconst currentError = ref(null)\n;(globalThis as any).resetError = () => { currentError.value = null; router.replace(router.getCurrent().path) }\nconst _push = router.push.bind(router)\nconst _replace = router.replace.bind(router)\nrouter.push = async (path) => { isNavigating.value = true; currentError.value = null; try { await _push(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\nrouter.replace = async (path) => { isNavigating.value = true; currentError.value = null; try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\n\nconst _pluginProvides = new Map()\n;(globalThis as any).__cerPluginProvides = _pluginProvides\n\ncomponent('cer-layout-view', () => {\n for (const [key, value] of _pluginProvides) { provide(key, value) }\n const current = ref(router.getCurrent())\n let unsub: (() => void) | undefined\n useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })\n useOnDisconnected(() => { unsub?.(); unsub = undefined })\n if (currentError.value !== null) {\n if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }\n return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: [String(currentError.value)] }\n }\n if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }\n const matched = router.matchRoute(current.value.path)\n const layoutName = (matched?.route as any)?.meta?.layout ?? 'default'\n const layoutTag = (layouts as Record<string, string>)[layoutName]\n const routerView = { tag: 'router-view', props: {}, children: [] }\n return layoutTag ? { tag: layoutTag, props: {}, children: [routerView] } : routerView\n})\n\nfor (const plugin of plugins ?? []) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })\n }\n}\n\nif (typeof window !== 'undefined') {\n const _initMatch = router.matchRoute(window.location.pathname)\n if (_initMatch?.route?.load) {\n try { await _initMatch.route.load() } catch { /* non-fatal */ }\n }\n}\n\nif (typeof window !== 'undefined') {\n await _replace(window.location.pathname + window.location.search + window.location.hash)\n delete (globalThis as any).__CER_DATA__\n createDOMJITCSS().mount()\n}\n\nexport { router }\n`,
222
222
  'utf-8',
223
223
  )
224
224
 
@@ -8,6 +8,7 @@ import { hasError, errorTag } from 'virtual:cer-error'
8
8
  import {
9
9
  component,
10
10
  ref,
11
+ provide,
11
12
  useOnConnected,
12
13
  useOnDisconnected,
13
14
  registerBuiltinComponents,
@@ -41,7 +42,14 @@ router.replace = async (path) => {
41
42
  try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
42
43
  }
43
44
 
45
+ const _pluginProvides = new Map<string, unknown>()
46
+ ;(globalThis as any).__cerPluginProvides = _pluginProvides
47
+
44
48
  component('cer-layout-view', () => {
49
+ for (const [key, value] of _pluginProvides) {
50
+ provide(key, value)
51
+ }
52
+
45
53
  const current = ref(router.getCurrent())
46
54
  let unsub: (() => void) | undefined
47
55
  useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })
@@ -49,7 +57,7 @@ component('cer-layout-view', () => {
49
57
 
50
58
  if (currentError.value !== null) {
51
59
  if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
52
- return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: [String(currentError.value)] }
60
+ return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
53
61
  }
54
62
  if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }
55
63
 
@@ -62,12 +70,24 @@ component('cer-layout-view', () => {
62
70
 
63
71
  for (const plugin of plugins) {
64
72
  if (plugin && typeof plugin.setup === 'function') {
65
- await plugin.setup({ router, provide: (key, value) => { (globalThis as any)[key] = value }, config: {} })
73
+ await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })
74
+ }
75
+ }
76
+
77
+ // Pre-load the current page's route chunk AFTER plugins run so that
78
+ // cer-layout-view's first render (which calls provide()) completes before
79
+ // page components are defined. This ensures inject() in child components
80
+ // can find values stored by provide().
81
+ if (typeof window !== 'undefined') {
82
+ const _initMatch = router.matchRoute(window.location.pathname)
83
+ if (_initMatch?.route?.load) {
84
+ try { await _initMatch.route.load() } catch { /* non-fatal */ }
66
85
  }
67
86
  }
68
87
 
69
88
  if (typeof window !== 'undefined') {
70
- await router.replace(window.location.pathname + window.location.search + window.location.hash)
89
+ // Use the original (unwrapped) replace so isNavigating stays false on first paint.
90
+ await _replace(window.location.pathname + window.location.search + window.location.hash)
71
91
  createDOMJITCSS().mount()
72
92
  }
73
93
 
@@ -8,6 +8,7 @@ import { hasError, errorTag } from 'virtual:cer-error'
8
8
  import {
9
9
  component,
10
10
  ref,
11
+ provide,
11
12
  useOnConnected,
12
13
  useOnDisconnected,
13
14
  registerBuiltinComponents,
@@ -41,7 +42,14 @@ router.replace = async (path) => {
41
42
  try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
42
43
  }
43
44
 
45
+ const _pluginProvides = new Map<string, unknown>()
46
+ ;(globalThis as any).__cerPluginProvides = _pluginProvides
47
+
44
48
  component('cer-layout-view', () => {
49
+ for (const [key, value] of _pluginProvides) {
50
+ provide(key, value)
51
+ }
52
+
45
53
  const current = ref(router.getCurrent())
46
54
  let unsub: (() => void) | undefined
47
55
  useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })
@@ -49,7 +57,7 @@ component('cer-layout-view', () => {
49
57
 
50
58
  if (currentError.value !== null) {
51
59
  if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
52
- return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: [String(currentError.value)] }
60
+ return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
53
61
  }
54
62
  if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }
55
63
 
@@ -62,12 +70,28 @@ component('cer-layout-view', () => {
62
70
 
63
71
  for (const plugin of plugins) {
64
72
  if (plugin && typeof plugin.setup === 'function') {
65
- await plugin.setup({ router, provide: (key, value) => { (globalThis as any)[key] = value }, config: {} })
73
+ await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })
74
+ }
75
+ }
76
+
77
+ // Pre-load the current page's route chunk AFTER plugins run so that
78
+ // cer-layout-view's first render (which calls provide()) completes before
79
+ // page components are defined. This ensures inject() in child components
80
+ // can find values stored by provide().
81
+ if (typeof window !== 'undefined') {
82
+ const _initMatch = router.matchRoute(window.location.pathname)
83
+ if (_initMatch?.route?.load) {
84
+ try { await _initMatch.route.load() } catch { /* non-fatal */ }
66
85
  }
67
86
  }
68
87
 
69
88
  if (typeof window !== 'undefined') {
70
- await router.replace(window.location.pathname + window.location.search + window.location.hash)
89
+ // Use the original (unwrapped) replace so isNavigating stays false on first
90
+ // paint — the loading component must not flash over pre-rendered SSG content.
91
+ await _replace(window.location.pathname + window.location.search + window.location.hash)
92
+ // Clear SSR hydration data after initial navigation so subsequent navigations
93
+ // don't accidentally reuse it.
94
+ delete (globalThis as any).__CER_DATA__
71
95
  createDOMJITCSS().mount()
72
96
  }
73
97
 
@@ -6,12 +6,3 @@ component('page-index', () => {
6
6
  </div>
7
7
  `
8
8
  })
9
-
10
- // Export page metadata for SSG
11
- export const meta = {
12
- layout: 'default',
13
- ssg: {
14
- // No dynamic paths needed for the index page
15
- paths: async () => [],
16
- },
17
- }
@@ -8,6 +8,7 @@ import { hasError, errorTag } from 'virtual:cer-error'
8
8
  import {
9
9
  component,
10
10
  ref,
11
+ provide,
11
12
  useOnConnected,
12
13
  useOnDisconnected,
13
14
  registerBuiltinComponents,
@@ -41,7 +42,14 @@ router.replace = async (path) => {
41
42
  try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
42
43
  }
43
44
 
45
+ const _pluginProvides = new Map<string, unknown>()
46
+ ;(globalThis as any).__cerPluginProvides = _pluginProvides
47
+
44
48
  component('cer-layout-view', () => {
49
+ for (const [key, value] of _pluginProvides) {
50
+ provide(key, value)
51
+ }
52
+
45
53
  const current = ref(router.getCurrent())
46
54
  let unsub: (() => void) | undefined
47
55
  useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })
@@ -49,7 +57,7 @@ component('cer-layout-view', () => {
49
57
 
50
58
  if (currentError.value !== null) {
51
59
  if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
52
- return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: [String(currentError.value)] }
60
+ return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
53
61
  }
54
62
  if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }
55
63
 
@@ -62,12 +70,28 @@ component('cer-layout-view', () => {
62
70
 
63
71
  for (const plugin of plugins) {
64
72
  if (plugin && typeof plugin.setup === 'function') {
65
- await plugin.setup({ router, provide: (key, value) => { (globalThis as any)[key] = value }, config: {} })
73
+ await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })
74
+ }
75
+ }
76
+
77
+ // Pre-load the current page's route chunk AFTER plugins run so that
78
+ // cer-layout-view's first render (which calls provide()) completes before
79
+ // page components are defined. This ensures inject() in child components
80
+ // can find values stored by provide().
81
+ if (typeof window !== 'undefined') {
82
+ const _initMatch = router.matchRoute(window.location.pathname)
83
+ if (_initMatch?.route?.load) {
84
+ try { await _initMatch.route.load() } catch { /* non-fatal */ }
66
85
  }
67
86
  }
68
87
 
69
88
  if (typeof window !== 'undefined') {
70
- await router.replace(window.location.pathname + window.location.search + window.location.hash)
89
+ // Use the original (unwrapped) replace so isNavigating stays false on first
90
+ // paint — the loading component must not flash over pre-rendered SSR content.
91
+ await _replace(window.location.pathname + window.location.search + window.location.hash)
92
+ // Clear SSR hydration data after initial navigation so subsequent navigations
93
+ // don't accidentally reuse it.
94
+ delete (globalThis as any).__CER_DATA__
71
95
  createDOMJITCSS().mount()
72
96
  }
73
97
 
@@ -146,8 +146,9 @@ async function renderPath(
146
146
  * Writes the rendered HTML to the output directory.
147
147
  * path '/' -> dist/index.html
148
148
  * path '/about' -> dist/about/index.html
149
+ * @internal exported for unit testing
149
150
  */
150
- async function writeRenderedPath(
151
+ export async function writeRenderedPath(
151
152
  path: string,
152
153
  html: string,
153
154
  distDir: string,
@@ -40,6 +40,7 @@ import { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'
40
40
  import { registerEntityMap, renderToStringWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'
41
41
  import entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'
42
42
  import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
43
+ import { beginHeadCollection, endHeadCollection, serializeHeadTags } from '@jasonshimmy/vite-plugin-cer-app/composables'
43
44
 
44
45
  registerBuiltinComponents()
45
46
 
@@ -69,13 +70,16 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
69
70
  ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''
70
71
  const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart
71
72
  ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml
72
- // Hoist <style> elements from the SSR body into the document <head> so
73
- // JIT CSS rules are applied before the layout paints.
73
+ // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)
74
+ // from the SSR body into the document <head>. Plain <style> blocks without
75
+ // an id attribute belong to shadow DOM templates and must stay in place —
76
+ // hoisting them to <head> breaks shadow DOM style encapsulation (document
77
+ // styles do not pierce shadow roots), which is the root cause of FOUC.
74
78
  const headParts = ssrHead ? [ssrHead] : []
75
79
  let ssrBodyContent = ssrBody
76
80
  let pos = 0
77
81
  while (pos < ssrBodyContent.length) {
78
- const styleOpen = ssrBodyContent.indexOf('<style', pos)
82
+ const styleOpen = ssrBodyContent.indexOf('<style id=', pos)
79
83
  if (styleOpen < 0) break
80
84
  const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)
81
85
  if (styleClose < 0) break
@@ -95,7 +99,14 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
95
99
  '<div id="app">' + ssrBodyContent + '</div>')
96
100
  }
97
101
  const headAdditions = headParts.filter(Boolean).join('\\n')
98
- if (headAdditions) merged = merged.replace('</head>', headAdditions + '\\n</head>')
102
+ if (headAdditions) {
103
+ // If SSR provides a <title>, replace the client template's <title> so the
104
+ // SSR title wins (client template title is the fallback default).
105
+ if (headAdditions.includes('<title>')) {
106
+ merged = merged.replace(/<title>[^<]*<\\/title>/, '')
107
+ }
108
+ merged = merged.replace('</head>', headAdditions + '\\n</head>')
109
+ }
99
110
  return merged
100
111
  }
101
112
 
@@ -157,6 +168,9 @@ export const handler = async (req, res) => {
157
168
  ;(globalThis).__CER_DATA__ = loaderData
158
169
  }
159
170
 
171
+ // Begin collecting useHead() calls made during the synchronous render pass.
172
+ beginHeadCollection()
173
+
160
174
  // dsdPolyfill: false — we inject the polyfill manually after merging so it
161
175
  // lands at the end of <body>, not inside <cer-layout-view> light DOM where
162
176
  // scripts may not execute.
@@ -165,13 +179,19 @@ export const handler = async (req, res) => {
165
179
  router,
166
180
  })
167
181
 
182
+ // Collect and serialize any useHead() calls from the rendered components.
183
+ const headTags = serializeHeadTags(endHeadCollection())
184
+
168
185
  // Clear immediately after the synchronous render so the value never leaks
169
186
  // to the next request on this same server process.
170
187
  delete (globalThis).__CER_DATA__
171
188
 
189
+ // Merge loader data script + useHead() tags into the document head.
190
+ const headContent = [head, headTags].filter(Boolean).join('\\n')
191
+
172
192
  // Wrap the rendered body in a full HTML document and inject the head additions
173
193
  // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.
174
- const ssrHtml = \`<!DOCTYPE html><html><head>\${head ?? ''}</head><body>\${htmlWithStyles}</body></html>\`
194
+ const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${htmlWithStyles}</body></html>\`
175
195
 
176
196
  let finalHtml = _clientTemplate
177
197
  ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
@@ -259,7 +279,7 @@ export async function buildSSR(
259
279
  },
260
280
  },
261
281
  ssr: {
262
- noExternal: ['@jasonshimmy/custom-elements-runtime'],
282
+ noExternal: ['@jasonshimmy/custom-elements-runtime', '@jasonshimmy/vite-plugin-cer-app'],
263
283
  },
264
284
  })
265
285
 
@@ -1,3 +1,3 @@
1
- export { useHead } from './use-head.js'
1
+ export { useHead, beginHeadCollection, endHeadCollection, serializeHeadTags } from './use-head.js'
2
2
  export type { HeadInput } from './use-head.js'
3
3
  export { usePageData } from './use-page-data.js'
@@ -6,15 +6,18 @@ export interface HeadInput {
6
6
  style?: Array<Record<string, string>>
7
7
  }
8
8
 
9
- // SSR: global collector, reset per-request
10
- let _ssrCollector: HeadInput[] | null = null
9
+ // SSR: store collector on globalThis so all module instances (server entry +
10
+ // dynamically-imported page chunks) share the same reference. A module-level
11
+ // variable would create separate instances per chunk, breaking the singleton.
12
+ const _g = globalThis as Record<string, unknown>
13
+ const _KEY = '__CER_HEAD_COLLECTOR__'
11
14
 
12
15
  /**
13
16
  * Begin collecting head tags for an SSR render pass.
14
17
  * Call this before invoking the render function.
15
18
  */
16
19
  export function beginHeadCollection(): void {
17
- _ssrCollector = []
20
+ _g[_KEY] = [] as HeadInput[]
18
21
  }
19
22
 
20
23
  /**
@@ -22,8 +25,8 @@ export function beginHeadCollection(): void {
22
25
  * Resets the collector to null.
23
26
  */
24
27
  export function endHeadCollection(): HeadInput[] {
25
- const collected = _ssrCollector ?? []
26
- _ssrCollector = null
28
+ const collected = (_g[_KEY] as HeadInput[]) ?? []
29
+ _g[_KEY] = null
27
30
  return collected
28
31
  }
29
32
 
@@ -111,9 +114,10 @@ export function serializeHeadTags(heads: HeadInput[]): string {
111
114
  * - On the client: imperatively updates document.title and meta/link tags
112
115
  */
113
116
  export function useHead(input: HeadInput): void {
114
- if (_ssrCollector !== null) {
115
- // SSR mode
116
- _ssrCollector.push(input)
117
+ const collector = _g[_KEY] as HeadInput[] | null | undefined
118
+ if (collector != null) {
119
+ // SSR mode: push to the shared globalThis collector
120
+ collector.push(input)
117
121
  } else if (typeof document !== 'undefined') {
118
122
  // Client-side
119
123
  if (input.title !== undefined) {
@@ -57,13 +57,16 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
57
57
  ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''
58
58
  const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart
59
59
  ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml
60
- // Hoist <style> elements from the SSR body into the document <head> so
61
- // JIT CSS rules are applied before the layout paints.
60
+ // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)
61
+ // from the SSR body into the document <head>. Plain <style> blocks without
62
+ // an id attribute belong to shadow DOM templates and must stay in place —
63
+ // hoisting them to <head> breaks shadow DOM style encapsulation (document
64
+ // styles do not pierce shadow roots), which is the root cause of FOUC.
62
65
  const headParts = ssrHead ? [ssrHead] : []
63
66
  let ssrBodyContent = ssrBody
64
67
  let pos = 0
65
68
  while (pos < ssrBodyContent.length) {
66
- const styleOpen = ssrBodyContent.indexOf('<style', pos)
69
+ const styleOpen = ssrBodyContent.indexOf('<style id=', pos)
67
70
  if (styleOpen < 0) break
68
71
  const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)
69
72
  if (styleClose < 0) break
@@ -83,7 +86,14 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
83
86
  '<div id="app">' + ssrBodyContent + '</div>')
84
87
  }
85
88
  const headAdditions = headParts.filter(Boolean).join('\\n')
86
- if (headAdditions) merged = merged.replace('</head>', headAdditions + '\\n</head>')
89
+ if (headAdditions) {
90
+ // If SSR provides a <title>, replace the client template's <title> so the
91
+ // SSR title wins (client template title is the fallback default).
92
+ if (headAdditions.includes('<title>')) {
93
+ merged = merged.replace(/<title>[^<]*<\\/title>/, '')
94
+ }
95
+ merged = merged.replace('</head>', headAdditions + '\\n</head>')
96
+ }
87
97
  return merged
88
98
  }
89
99
 
package/vitest.config.ts CHANGED
@@ -19,10 +19,14 @@ export default defineConfig({
19
19
  include: ['src/**/*.ts'],
20
20
  exclude: [
21
21
  'src/cli/**',
22
- 'src/runtime/app-template.ts',
23
22
  'src/runtime/entry-client-template.ts',
24
23
  'src/runtime/entry-server-template.ts',
25
24
  'src/__tests__/**',
25
+ // Pure type-only files — no executable statements, V8 cannot instrument them
26
+ 'src/types/**',
27
+ // Barrel re-export files — only `export { } from ...` lines, no logic
28
+ 'src/index.ts',
29
+ 'src/runtime/composables/index.ts',
26
30
  ],
27
31
  },
28
32
  },