@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
@@ -67,7 +67,7 @@ interface PageLoaderContext<P extends Record<string, string>> {
67
67
 
68
68
  1. Browser receives the full HTML — content is immediately visible via Declarative Shadow DOM before any JS runs
69
69
  2. Client JS boots; `usePageData()` reads `window.__CER_DATA__` and returns the hydrated values
70
- 3. The value is cleared after the first read so subsequent client-side navigations trigger a fresh fetch
70
+ 3. After the initial `router.replace()` in `app/app.ts` completes, `window.__CER_DATA__` is deleted so subsequent client-side navigations trigger a fresh fetch instead of reusing stale server data
71
71
  4. Components that received SSR data skip their `useOnConnected` fetch — no duplicate request
72
72
 
73
73
  ---
@@ -164,3 +164,47 @@ export const loader: PageLoader<
164
164
  return { user: await fetchUser(params.id), posts: await fetchPosts(params.id) }
165
165
  }
166
166
  ```
167
+
168
+ ---
169
+
170
+ ## Multi-mode data loading (SPA fallback)
171
+
172
+ When building a page that needs to work in **all three modes** — SSR/SSG (with a `loader`) and SPA (no server, no loader) — use the following pattern:
173
+
174
+ 1. In SSR/SSG, the server runs `loader` and injects the data via `window.__CER_DATA__`. `usePageData()` returns it immediately; `useOnConnected` sees `ssrData` and skips the client fetch.
175
+ 2. In SPA mode there is no server, so `ssrData` is `null`. The client tries the API first, then falls back to a direct module import.
176
+
177
+ ```ts
178
+ // app/pages/blog/index.ts
179
+ component('page-blog-index', () => {
180
+ const ssrData = usePageData<{ posts: Post[] }>()
181
+ const posts = ref<Post[]>(ssrData?.posts ?? [])
182
+
183
+ useOnConnected(async () => {
184
+ if (ssrData) return // SSR/SSG: already hydrated, skip the fetch
185
+
186
+ // SPA: try the API server first
187
+ try {
188
+ const r = await fetch('/api/posts')
189
+ if (r.ok) {
190
+ posts.value = await r.json()
191
+ return
192
+ }
193
+ } catch { /* no API server in static preview */ }
194
+
195
+ // SPA static fallback: import data directly from source
196
+ const { posts: staticPosts } = await import('../data/posts')
197
+ posts.value = staticPosts
198
+ })
199
+
200
+ return html`<ul>${posts.value.map(p => html`<li>${p.title}</li>`)}</ul>`
201
+ })
202
+
203
+ // loader runs in SSR and SSG only
204
+ export const loader = async () => {
205
+ const posts = await fetch('/api/posts').then(r => r.json())
206
+ return { posts }
207
+ }
208
+ ```
209
+
210
+ The key rule: **always check `ssrData` before fetching on the client**. This prevents a redundant network request when the data was already serialized by the server.
@@ -135,40 +135,105 @@ component('layout-default', () => {
135
135
 
136
136
  ### 7. Create `app/app.ts` (auto-generated if absent)
137
137
 
138
- The framework auto-generates this file when you scaffold a new project. It includes the runtime CSS, DOM JIT CSS for light-DOM utility classes, and the router bootstrap:
138
+ The framework generates this file when you scaffold a new project. It bootstraps the router, registers all auto-discovered components, runs plugins, and mounts the app:
139
139
 
140
140
  ```ts
141
141
  // app/app.ts
142
142
  import '@jasonshimmy/custom-elements-runtime/css'
143
143
  import 'virtual:cer-components'
144
144
  import routes from 'virtual:cer-routes'
145
+ import layouts from 'virtual:cer-layouts'
145
146
  import plugins from 'virtual:cer-plugins'
146
- import { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'
147
+ import { hasLoading, loadingTag } from 'virtual:cer-loading'
148
+ import { hasError, errorTag } from 'virtual:cer-error'
149
+ import {
150
+ component, ref, provide,
151
+ useOnConnected, useOnDisconnected,
152
+ registerBuiltinComponents,
153
+ } from '@jasonshimmy/custom-elements-runtime'
147
154
  import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
148
155
  import { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'
149
156
  import { createDOMJITCSS } from '@jasonshimmy/custom-elements-runtime/dom-jit-css'
150
157
 
151
158
  registerBuiltinComponents()
152
-
153
- // Enable JIT CSS globally for all Shadow DOM components.
154
159
  enableJITCSS()
155
160
 
156
161
  const router = initRouter({ routes })
157
162
 
163
+ const isNavigating = ref(false)
164
+ const currentError = ref(null)
165
+ ;(globalThis as any).resetError = () => {
166
+ currentError.value = null
167
+ router.replace(router.getCurrent().path)
168
+ }
169
+
170
+ const _push = router.push.bind(router)
171
+ const _replace = router.replace.bind(router)
172
+ router.push = async (path) => {
173
+ isNavigating.value = true; currentError.value = null
174
+ try { await _push(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
175
+ }
176
+ router.replace = async (path) => {
177
+ isNavigating.value = true; currentError.value = null
178
+ try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
179
+ }
180
+
181
+ // _pluginProvides is populated by plugin setup and forwarded into the component
182
+ // context tree via provide() inside cer-layout-view so inject() works in all modes.
183
+ // Also exposed on globalThis for the SSG timing edge case — see docs/plugins.md.
184
+ const _pluginProvides = new Map<string, unknown>()
185
+ ;(globalThis as any).__cerPluginProvides = _pluginProvides
186
+
187
+ component('cer-layout-view', () => {
188
+ for (const [key, value] of _pluginProvides) { provide(key, value) }
189
+
190
+ const current = ref(router.getCurrent())
191
+ let unsub: (() => void) | undefined
192
+ useOnConnected(() => { unsub = router.subscribe((s) => { current.value = s }) })
193
+ useOnDisconnected(() => { unsub?.(); unsub = undefined })
194
+
195
+ if (currentError.value !== null) {
196
+ if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
197
+ return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
198
+ }
199
+ if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }
200
+
201
+ const matched = router.matchRoute(current.value.path)
202
+ const layoutName = (matched?.route as any)?.meta?.layout ?? 'default'
203
+ const layoutTag = (layouts as Record<string, string>)[layoutName]
204
+ const routerView = { tag: 'router-view', props: {}, children: [] }
205
+ return layoutTag ? { tag: layoutTag, props: {}, children: [routerView] } : routerView
206
+ })
207
+
208
+ // Plugins run AFTER cer-layout-view is defined so provide() calls from plugins
209
+ // are forwarded into the component tree on the very first render.
158
210
  for (const plugin of plugins ?? []) {
159
211
  if (plugin && typeof plugin.setup === 'function') {
160
- await plugin.setup({ router, provide: (key, value) => { globalThis[key] = value }, config: {} })
212
+ await plugin.setup({ router, provide: (key, value) => { _pluginProvides.set(key, value) }, config: {} })
161
213
  }
162
214
  }
163
215
 
216
+ // Pre-load the current page's route chunk AFTER plugins run.
217
+ // This ensures cer-layout-view's first render (and its provide() calls) completes
218
+ // before page component modules are imported and their renders are scheduled.
164
219
  if (typeof window !== 'undefined') {
165
- await router.replace(window.location.pathname + window.location.search + window.location.hash)
220
+ const _initMatch = router.matchRoute(window.location.pathname)
221
+ if (_initMatch?.route?.load) {
222
+ try { await _initMatch.route.load() } catch { /* non-fatal */ }
223
+ }
224
+ }
225
+
226
+ if (typeof window !== 'undefined') {
227
+ await _replace(window.location.pathname + window.location.search + window.location.hash)
228
+ delete (globalThis as any).__CER_DATA__
166
229
  createDOMJITCSS().mount()
167
230
  }
168
231
 
169
232
  export { router }
170
233
  ```
171
234
 
235
+ > **Note:** Do not move the plugin loop before `component('cer-layout-view', …)`. The layout component must be defined first so that when plugins call `app.provide()`, the values are available to the component tree from the very first render. See [Plugins](plugins.md) for details.
236
+
172
237
  ---
173
238
 
174
239
  ## Running the dev server
@@ -9,15 +9,21 @@ The `useHead()` composable manages `<title>`, `<meta>`, `<link>`, `<script>`, an
9
9
 
10
10
  ## Import
11
11
 
12
+ `useHead` is **not** auto-imported. You must explicitly import it in every file that uses it:
13
+
12
14
  ```ts
13
15
  import { useHead } from '@jasonshimmy/vite-plugin-cer-app/composables'
14
16
  ```
15
17
 
18
+ Unlike `component`, `html`, `ref`, and other runtime APIs (which are injected automatically when `autoImports.runtime` is enabled), `useHead` comes from the framework package and requires an explicit import statement.
19
+
16
20
  ---
17
21
 
18
22
  ## Basic usage
19
23
 
20
24
  ```ts
25
+ import { useHead } from '@jasonshimmy/vite-plugin-cer-app/composables'
26
+
21
27
  component('page-about', () => {
22
28
  useHead({
23
29
  title: 'About Us',
package/docs/plugins.md CHANGED
@@ -120,6 +120,31 @@ export default {
120
120
 
121
121
  ---
122
122
 
123
+ ## SSG and `inject()`
124
+
125
+ In SSG mode there is a timing subtlety: the router loads a page chunk and renders it before `<cer-layout-view>` has called `provide()`. This means `inject()` may return `undefined` on the first render in SSG — even though the plugin ran and called `app.provide()` correctly.
126
+
127
+ To write pages that work correctly in **all three modes** (SPA, SSR, SSG), use `globalThis.__cerPluginProvides` as a synchronous fallback when `inject()` returns `undefined`:
128
+
129
+ ```ts
130
+ // app/pages/dashboard.ts
131
+ component('page-dashboard', () => {
132
+ // inject() resolves correctly in SPA and SSR modes.
133
+ // In SSG the page chunk may render before cer-layout-view calls provide(),
134
+ // so fall back to the global map that app/app.ts populates before any render.
135
+ const pluginProvides = (globalThis as any).__cerPluginProvides as Map<string, unknown> | undefined
136
+ const store = inject<Store>('store') ?? pluginProvides?.get('store') as Store | undefined
137
+
138
+ if (!store) return html`<p>Loading…</p>`
139
+
140
+ return html`<p>Count: ${store.state.count}</p>`
141
+ })
142
+ ```
143
+
144
+ The `__cerPluginProvides` map is written by the bootstrapped `app/app.ts` before any route is rendered, so it is always available as a synchronous fallback regardless of render order.
145
+
146
+ ---
147
+
123
148
  ## Virtual module
124
149
 
125
150
  The sorted plugin list is available via `virtual:cer-plugins`:
package/docs/routing.md CHANGED
@@ -13,15 +13,17 @@ Routes are automatically derived from files in the `app/pages/` directory. No ma
13
13
  | `app/pages/blog/index.ts` | `/blog` | `page-blog-index` |
14
14
  | `app/pages/blog/[slug].ts` | `/blog/:slug` | `page-blog-slug` |
15
15
  | `app/pages/users/[id]/edit.ts` | `/users/:id/edit` | `page-users-id-edit` |
16
- | `app/pages/[...all].ts` | `/*` (catch-all) | `page-all` |
16
+ | `app/pages/404.ts` | `/:all*` (catch-all) | `page-404` |
17
+ | `app/pages/[...all].ts` | `/:all*` (catch-all) | `page-all` |
17
18
  | `app/pages/(auth)/login.ts` | `/login` | `page-login` |
18
19
 
19
20
  ### Rules
20
21
 
21
22
  1. **`index.ts`** — The `index` segment is stripped: `blog/index.ts` → `/blog`
22
23
  2. **`[param]`** — Dynamic segment: `[slug].ts` → `:slug`
23
- 3. **`[...rest]`** — Catch-all segment: `[...all].ts` → `/*`
24
- 4. **`(group)/`**Route group: directory name stripped from path, not from tag name
24
+ 3. **`[...rest]`** — Catch-all segment: `[...all].ts` → `/:all*`
25
+ 4. **`404.ts`**Special shorthand: treated as a catch-all (`/:all*`) the conventional 404 page
26
+ 5. **`(group)/`** — Route group: directory name stripped from path, not from tag name
25
27
 
26
28
  ---
27
29
 
@@ -57,9 +59,27 @@ The `:slug` param is populated by the router and passed as a prop to the compone
57
59
 
58
60
  ---
59
61
 
60
- ## Catch-all routes
62
+ ## Catch-all routes and 404 pages
61
63
 
62
- A file named `[...anything].ts` matches any path not matched by a more specific route:
64
+ There are two equivalent ways to define a catch-all / 404 page:
65
+
66
+ **Option A — `404.ts` (recommended shorthand)**
67
+
68
+ ```ts
69
+ // app/pages/404.ts
70
+ component('page-404', () => {
71
+ return html`
72
+ <div>
73
+ <h1>404 — Page not found</h1>
74
+ <p><a href="/">← Back home</a></p>
75
+ </div>
76
+ `
77
+ })
78
+ ```
79
+
80
+ The framework special-cases `404.ts` and automatically registers it as the catch-all route (`/:all*`). This is the conventional name and is the approach used by the kitchen-sink example.
81
+
82
+ **Option B — `[...name].ts` explicit catch-all**
63
83
 
64
84
  ```ts
65
85
  // app/pages/[...all].ts
@@ -68,7 +88,7 @@ component('page-all', () => {
68
88
  })
69
89
  ```
70
90
 
71
- This also serves as the 404 page. The catch-all segment matches `/*`.
91
+ Both produce the same route (`/:all*`) and either form works. Use `404.ts` for clarity.
72
92
 
73
93
  ---
74
94
 
@@ -166,6 +186,28 @@ Within each tier, routes are sorted alphabetically.
166
186
 
167
187
  ---
168
188
 
189
+ ## Navigation with `<router-link>`
190
+
191
+ `initRouter()` registers a `<router-link>` built-in custom element that renders a client-side navigation link. Use it anywhere in your pages or layouts instead of a plain `<a>` tag when you want the router to handle the navigation (no full page reload):
192
+
193
+ ```ts
194
+ // app/layouts/default.ts
195
+ component('layout-default', () => {
196
+ return html`
197
+ <nav>
198
+ <router-link to="/">Home</router-link>
199
+ <router-link to="/about">About</router-link>
200
+ <router-link to="/blog">Blog</router-link>
201
+ </nav>
202
+ <main><slot></slot></main>
203
+ `
204
+ })
205
+ ```
206
+
207
+ `<router-link to="/path">` calls `router.push(path)` on click, which triggers the wrapped navigation handler (loading state, error capture, etc.). Use a plain `<a href="/path">` when you need a standard browser navigation or an external link.
208
+
209
+ ---
210
+
169
211
  ## Virtual module
170
212
 
171
213
  The route list is exposed as the virtual module `virtual:cer-routes`, which you can import directly in `app/app.ts`:
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Server API route tests.
3
+ *
4
+ * Only run in SSR mode — SSG and SPA don't have live API endpoints.
5
+ */
6
+
7
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
8
+
9
+ if (mode !== 'ssr') {
10
+ describe('Server API routes', () => {
11
+ it('skipped — only tested in SSR mode', () => {
12
+ cy.log(`API tests skipped in ${mode} mode`)
13
+ })
14
+ })
15
+ } else {
16
+ describe('Server API routes (SSR mode)', () => {
17
+ context('GET /api/health', () => {
18
+ it('returns 200 with status ok', () => {
19
+ cy.request('/api/health').then((response) => {
20
+ expect(response.status).to.eq(200)
21
+ expect(response.body.status).to.eq('ok')
22
+ expect(response.body.service).to.eq('kitchen-sink')
23
+ })
24
+ })
25
+ })
26
+
27
+ context('GET /api/posts', () => {
28
+ it('returns 200 with array of posts', () => {
29
+ cy.request('/api/posts').then((response) => {
30
+ expect(response.status).to.eq(200)
31
+ expect(response.body).to.be.an('array')
32
+ expect(response.body.length).to.be.greaterThan(0)
33
+ })
34
+ })
35
+
36
+ it('first post has slug, title, excerpt, body', () => {
37
+ cy.request('/api/posts').then((response) => {
38
+ const post = response.body[0]
39
+ expect(post).to.have.property('slug')
40
+ expect(post).to.have.property('title')
41
+ expect(post).to.have.property('excerpt')
42
+ expect(post).to.have.property('body')
43
+ })
44
+ })
45
+
46
+ it('contains first-post and second-post', () => {
47
+ cy.request('/api/posts').then((response) => {
48
+ const slugs = response.body.map((p: any) => p.slug)
49
+ expect(slugs).to.include('first-post')
50
+ expect(slugs).to.include('second-post')
51
+ })
52
+ })
53
+ })
54
+
55
+ context('GET /api/posts/:slug', () => {
56
+ it('returns the correct post for first-post', () => {
57
+ cy.request('/api/posts/first-post').then((response) => {
58
+ expect(response.status).to.eq(200)
59
+ expect(response.body.slug).to.eq('first-post')
60
+ expect(response.body.title).to.eq('First Post')
61
+ expect(response.body.body).to.include('First post body content')
62
+ })
63
+ })
64
+
65
+ it('returns the correct post for second-post', () => {
66
+ cy.request('/api/posts/second-post').then((response) => {
67
+ expect(response.status).to.eq(200)
68
+ expect(response.body.slug).to.eq('second-post')
69
+ expect(response.body.title).to.eq('Second Post')
70
+ })
71
+ })
72
+
73
+ it('returns 404 for unknown slug', () => {
74
+ cy.request({ url: '/api/posts/not-a-post', failOnStatusCode: false }).then((response) => {
75
+ expect(response.status).to.eq(404)
76
+ expect(response.body).to.have.property('error')
77
+ })
78
+ })
79
+ })
80
+ })
81
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Data loading tests — page loaders, usePageData, dynamic params.
3
+ *
4
+ * Blog data is loaded via a page `loader` in SSR/SSG mode and falls back to
5
+ * client-side fetch in SPA mode. Item IDs come from route params (all modes).
6
+ */
7
+
8
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
9
+
10
+ describe('Blog list — data loader', () => {
11
+ // In SSR/SSG the loader runs server-side; data is embedded in HTML.
12
+ // In SPA the page fetches from /api/posts on mount.
13
+
14
+ if (mode !== 'spa') {
15
+ it('renders pre-loaded posts in initial HTML (SSR/SSG)', () => {
16
+ cy.request('/blog').then((response) => {
17
+ expect(response.body).to.include('First Post')
18
+ expect(response.body).to.include('Second Post')
19
+ })
20
+ })
21
+ }
22
+
23
+ it('blog list renders at least 2 items after hydration', () => {
24
+ cy.visit('/blog')
25
+ cy.get('[data-cy=blog-list]').should('exist')
26
+ // Items appear after hydration (SSR) or fetch (SPA)
27
+ cy.get('[data-cy=blog-item]', { timeout: 8000 }).should('have.length.at.least', 2)
28
+ })
29
+
30
+ it('First Post link is present', () => {
31
+ cy.visit('/blog')
32
+ cy.get('[data-cy=blog-item]').should('have.length.at.least', 1)
33
+ cy.get('[data-cy="blog-link-first-post"]').should('exist')
34
+ })
35
+
36
+ it('Second Post link is present', () => {
37
+ cy.visit('/blog')
38
+ cy.get('[data-cy="blog-link-second-post"]').should('exist')
39
+ })
40
+ })
41
+
42
+ describe('Blog detail — dynamic route with loader', () => {
43
+ it('renders "First Post" title for /blog/first-post', () => {
44
+ cy.visit('/blog/first-post')
45
+ cy.get('[data-cy=post-title]').should('contain', 'First Post')
46
+ })
47
+
48
+ it('renders "Second Post" title for /blog/second-post', () => {
49
+ cy.visit('/blog/second-post')
50
+ cy.get('[data-cy=post-title]').should('contain', 'Second Post')
51
+ })
52
+
53
+ it('shows the correct slug in the post', () => {
54
+ cy.visit('/blog/first-post')
55
+ cy.get('[data-cy=post-slug]').should('contain', 'first-post')
56
+ })
57
+
58
+ if (mode !== 'spa') {
59
+ it('body content is pre-rendered in initial HTML (SSR/SSG)', () => {
60
+ cy.request('/blog/first-post').then((response) => {
61
+ expect(response.body).to.include('First post body content')
62
+ })
63
+ })
64
+
65
+ it('second post body is pre-rendered (SSR/SSG)', () => {
66
+ cy.request('/blog/second-post').then((response) => {
67
+ expect(response.body).to.include('Second post body content')
68
+ })
69
+ })
70
+ }
71
+
72
+ it('renders the body content after hydration', () => {
73
+ cy.visit('/blog/first-post')
74
+ cy.get('[data-cy=post-body]', { timeout: 8000 }).should('contain', 'First post body content')
75
+ })
76
+
77
+ it('back link navigates to /blog', () => {
78
+ cy.visit('/blog/first-post')
79
+ cy.get('[data-cy=post-back]').first().click({ force: true })
80
+ cy.url().should('include', '/blog')
81
+ cy.get('[data-cy=blog-heading]').should('contain', 'Blog')
82
+ })
83
+ })
84
+
85
+ describe('Item detail — route params via useProps', () => {
86
+ it('shows item ID 1 for /items/1', () => {
87
+ cy.visit('/items/1')
88
+ cy.get('[data-cy=item-id]').should('contain', '1')
89
+ })
90
+
91
+ it('shows item ID 2 for /items/2', () => {
92
+ cy.visit('/items/2')
93
+ cy.get('[data-cy=item-id]').should('contain', '2')
94
+ })
95
+
96
+ it('shows correct ID for client-side nav to /items/2 from /items/1', () => {
97
+ cy.visit('/items/1')
98
+ cy.get('[data-cy=item-id]').should('contain', '1')
99
+ // Navigate via browser history pushState via the router
100
+ cy.window().then((win) => {
101
+ // Trigger client-side navigation
102
+ const router = (win as any).__cer_router__ ?? (win as any).router
103
+ if (router?.push) {
104
+ router.push('/items/2')
105
+ }
106
+ })
107
+ // Fallback: visit directly
108
+ cy.visit('/items/2')
109
+ cy.get('[data-cy=item-id]').should('contain', '2')
110
+ })
111
+ })
@@ -0,0 +1,65 @@
1
+ /**
2
+ * FOUC (Flash of Unstyled Content) tests.
3
+ *
4
+ * For SSR and SSG modes, validates that the server-rendered HTML:
5
+ * 1. Contains Declarative Shadow DOM templates
6
+ * 2. Each shadow template has its own embedded <style> block
7
+ * 3. Shadow DOM styles are NOT hoisted to <head> (which would break encapsulation)
8
+ * 4. The loading indicator is NOT present in the initial HTML
9
+ * 5. cer-layout-view has pre-rendered content (not empty)
10
+ *
11
+ * FOUC occurs when styles are missing from shadow roots on first parse.
12
+ * These checks verify the DSD structure prevents FOUC before JS hydrates.
13
+ */
14
+
15
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
16
+
17
+ // Routes to check for FOUC — all pre-renderable routes
18
+ const ROUTES_TO_CHECK = [
19
+ '/',
20
+ '/about',
21
+ '/counter',
22
+ '/head',
23
+ '/blog',
24
+ '/blog/first-post',
25
+ '/blog/second-post',
26
+ '/items/1',
27
+ '/items/2',
28
+ '/login',
29
+ ]
30
+
31
+ describe('FOUC prevention', () => {
32
+ before(() => {
33
+ if (mode === 'spa') {
34
+ cy.log('Skipping FOUC tests in SPA mode (no server-side rendering)')
35
+ }
36
+ })
37
+
38
+ if (mode === 'spa') return
39
+
40
+ ROUTES_TO_CHECK.forEach((path) => {
41
+ it(`${path}: shadow templates have embedded styles (no FOUC)`, () => {
42
+ cy.assertNoDSD_FOUC(path)
43
+ })
44
+ })
45
+
46
+ it('DSD polyfill is placed after </cer-layout-view>, not inside it', () => {
47
+ cy.request('/').then((response) => {
48
+ const html: string = response.body
49
+ const clvEnd = html.lastIndexOf('</cer-layout-view>')
50
+ const polyfillIdx = html.indexOf('attachShadow')
51
+ if (polyfillIdx >= 0) {
52
+ expect(polyfillIdx, 'DSD polyfill must come AFTER </cer-layout-view>').to.be.greaterThan(clvEnd)
53
+ }
54
+ })
55
+ })
56
+
57
+ it('home page: loading indicator is not visible on first paint', () => {
58
+ // The loading component (page-loading) should never appear in initial HTML.
59
+ // If it does, it means isNavigating was true during SSR — a FOUC bug.
60
+ cy.request('/').then((response) => {
61
+ expect(response.body).not.to.include('page-loading')
62
+ expect(response.body).not.to.include('data-cy="loading-indicator"')
63
+ })
64
+ })
65
+ })
@@ -0,0 +1,89 @@
1
+ /**
2
+ * useHead() tests — verifies document title and meta tags are set correctly.
3
+ *
4
+ * In SSR/SSG: tags should be in the initial HTML (server-side injection).
5
+ * In all modes: tags should be set after client-side hydration.
6
+ */
7
+
8
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
9
+
10
+ describe('useHead() — document title', () => {
11
+ it('sets title to "Home — Kitchen Sink" on home page', () => {
12
+ cy.visit('/')
13
+ cy.title().should('eq', 'Home — Kitchen Sink')
14
+ })
15
+
16
+ it('sets title to "About — Kitchen Sink" on about page', () => {
17
+ cy.visit('/about')
18
+ cy.title().should('eq', 'About — Kitchen Sink')
19
+ })
20
+
21
+ it('sets title to "Head Test — Kitchen Sink" on head page', () => {
22
+ cy.visit('/head')
23
+ cy.title().should('eq', 'Head Test — Kitchen Sink')
24
+ })
25
+
26
+ it('sets title to "Blog — Kitchen Sink" on blog list page', () => {
27
+ cy.visit('/blog')
28
+ cy.title().should('eq', 'Blog — Kitchen Sink')
29
+ })
30
+
31
+ it('updates title when navigating between pages', () => {
32
+ cy.visit('/')
33
+ cy.title().should('eq', 'Home — Kitchen Sink')
34
+ cy.get('[data-cy=nav-about]').first().click({ force: true })
35
+ cy.title().should('eq', 'About — Kitchen Sink')
36
+ cy.get('[data-cy=about-back]').first().click({ force: true })
37
+ cy.title().should('eq', 'Home — Kitchen Sink')
38
+ })
39
+ })
40
+
41
+ describe('useHead() — meta tags', () => {
42
+ it('sets meta description on home page', () => {
43
+ cy.visit('/')
44
+ cy.get('meta[name="description"]').should('have.attr', 'content', 'Kitchen sink test app.')
45
+ })
46
+
47
+ it('sets meta description on about page', () => {
48
+ cy.visit('/about')
49
+ cy.get('meta[name="description"]').should('have.attr', 'content', 'About the kitchen sink test app.')
50
+ })
51
+
52
+ it('sets meta description on head test page', () => {
53
+ cy.visit('/head')
54
+ cy.get('meta[name="description"]').should('have.attr', 'content', 'A test page for useHead().')
55
+ })
56
+
57
+ it('sets og:title meta on head test page', () => {
58
+ cy.visit('/head')
59
+ cy.get('meta[property="og:title"]').should('have.attr', 'content', 'Head Test')
60
+ })
61
+ })
62
+
63
+ if (mode !== 'spa') {
64
+ describe('useHead() — server-side injection (SSR/SSG)', () => {
65
+ it('home page title is in the initial HTML', () => {
66
+ cy.request('/').then((response) => {
67
+ expect(response.body).to.include('<title>Home — Kitchen Sink</title>')
68
+ })
69
+ })
70
+
71
+ it('about page title is in the initial HTML', () => {
72
+ cy.request('/about').then((response) => {
73
+ expect(response.body).to.include('<title>About — Kitchen Sink</title>')
74
+ })
75
+ })
76
+
77
+ it('head test page title is in the initial HTML', () => {
78
+ cy.request('/head').then((response) => {
79
+ expect(response.body).to.include('<title>Head Test — Kitchen Sink</title>')
80
+ })
81
+ })
82
+
83
+ it('meta description is in the initial HTML for home', () => {
84
+ cy.request('/').then((response) => {
85
+ expect(response.body).to.include('Kitchen sink test app.')
86
+ })
87
+ })
88
+ })
89
+ }