@jasonshimmy/vite-plugin-cer-app 0.18.2 → 0.19.0

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 (83) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/commits.txt +2 -1
  3. package/dist/cli/create/templates/spa/package.json.tpl +1 -1
  4. package/dist/cli/create/templates/ssg/package.json.tpl +1 -1
  5. package/dist/cli/create/templates/ssr/package.json.tpl +1 -1
  6. package/dist/plugin/index.d.ts.map +1 -1
  7. package/dist/plugin/index.js +14 -0
  8. package/dist/plugin/index.js.map +1 -1
  9. package/dist/runtime/composables/use-auth.d.ts +6 -0
  10. package/dist/runtime/composables/use-auth.d.ts.map +1 -1
  11. package/dist/runtime/composables/use-auth.js.map +1 -1
  12. package/dist/runtime/composables/use-cookie.d.ts +2 -0
  13. package/dist/runtime/composables/use-cookie.d.ts.map +1 -1
  14. package/dist/runtime/composables/use-cookie.js.map +1 -1
  15. package/dist/runtime/composables/use-fetch.d.ts +1 -0
  16. package/dist/runtime/composables/use-fetch.d.ts.map +1 -1
  17. package/dist/runtime/composables/use-fetch.js.map +1 -1
  18. package/dist/runtime/composables/use-head.d.ts +4 -0
  19. package/dist/runtime/composables/use-head.d.ts.map +1 -1
  20. package/dist/runtime/composables/use-head.js.map +1 -1
  21. package/dist/runtime/composables/use-locale.d.ts +1 -0
  22. package/dist/runtime/composables/use-locale.d.ts.map +1 -1
  23. package/dist/runtime/composables/use-locale.js.map +1 -1
  24. package/dist/runtime/composables/use-runtime-config.d.ts +3 -0
  25. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
  26. package/dist/runtime/composables/use-runtime-config.js +17 -4
  27. package/dist/runtime/composables/use-runtime-config.js.map +1 -1
  28. package/dist/runtime/composables/use-seo-meta.d.ts +5 -0
  29. package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -1
  30. package/dist/runtime/composables/use-seo-meta.js.map +1 -1
  31. package/dist/runtime/composables/use-session.d.ts +5 -0
  32. package/dist/runtime/composables/use-session.d.ts.map +1 -1
  33. package/dist/runtime/composables/use-session.js.map +1 -1
  34. package/dist/runtime/entry-server-template.d.ts +1 -1
  35. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  36. package/dist/runtime/entry-server-template.js +21 -1
  37. package/dist/runtime/entry-server-template.js.map +1 -1
  38. package/dist/runtime/isr-handler.d.ts +2 -0
  39. package/dist/runtime/isr-handler.d.ts.map +1 -1
  40. package/dist/runtime/isr-handler.js.map +1 -1
  41. package/dist/types/api.d.ts +21 -0
  42. package/dist/types/api.d.ts.map +1 -1
  43. package/dist/types/config.d.ts +120 -0
  44. package/dist/types/config.d.ts.map +1 -1
  45. package/dist/types/config.js +19 -0
  46. package/dist/types/config.js.map +1 -1
  47. package/dist/types/index.d.ts +1 -1
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/types/middleware.d.ts +16 -0
  50. package/dist/types/middleware.d.ts.map +1 -1
  51. package/dist/types/page.d.ts +56 -0
  52. package/dist/types/page.d.ts.map +1 -1
  53. package/dist/types/plugin.d.ts +21 -0
  54. package/dist/types/plugin.d.ts.map +1 -1
  55. package/docs/authentication.md +18 -0
  56. package/docs/configuration.md +126 -1
  57. package/e2e/cypress/e2e/observability.cy.ts +77 -0
  58. package/e2e/kitchen-sink/app/pages/observability-test.ts +25 -0
  59. package/e2e/kitchen-sink/cer.config.ts +14 -0
  60. package/package.json +1 -1
  61. package/src/__tests__/plugin/entry-server-template.test.ts +50 -0
  62. package/src/__tests__/runtime/use-runtime-config.test.ts +40 -1
  63. package/src/cli/create/templates/spa/package.json.tpl +1 -1
  64. package/src/cli/create/templates/ssg/package.json.tpl +1 -1
  65. package/src/cli/create/templates/ssr/package.json.tpl +1 -1
  66. package/src/plugin/index.ts +13 -0
  67. package/src/runtime/composables/use-auth.ts +6 -0
  68. package/src/runtime/composables/use-cookie.ts +2 -0
  69. package/src/runtime/composables/use-fetch.ts +1 -0
  70. package/src/runtime/composables/use-head.ts +4 -0
  71. package/src/runtime/composables/use-locale.ts +1 -0
  72. package/src/runtime/composables/use-runtime-config.ts +23 -3
  73. package/src/runtime/composables/use-seo-meta.ts +5 -0
  74. package/src/runtime/composables/use-session.ts +5 -0
  75. package/src/runtime/entry-server-template.ts +21 -1
  76. package/src/runtime/isr-handler.ts +2 -0
  77. package/src/types/api.ts +21 -0
  78. package/src/types/config.ts +126 -1
  79. package/src/types/index.ts +1 -1
  80. package/src/types/middleware.ts +16 -0
  81. package/src/types/page.ts +58 -2
  82. package/src/types/plugin.ts +21 -0
  83. package/docs/plan-production-hardening.md +0 -1010
@@ -1,1010 +0,0 @@
1
- # Implementation Plan: Production Hardening
2
-
3
- ## Overview
4
-
5
- This plan addresses every gap identified in the evidence-based production-readiness audit.
6
- Items are ordered by severity: critical blockers first, minor gaps second, feature parity
7
- gaps last. Each item includes the exact diagnosis (file + line), the fix design, all
8
- implementation steps, and required tests.
9
-
10
- ---
11
-
12
- ## Priority legend
13
-
14
- | Priority | Meaning |
15
- |---|---|
16
- | **P0 — Critical** | Can crash or corrupt an active request in production |
17
- | **P1 — Minor** | Incorrect behavior or security gap; not immediately fatal |
18
- | **P2 — Feature parity** | Present in Nuxt/Next.js; absence limits use cases |
19
-
20
- ---
21
-
22
- ## P0-1 — SSR render errors crash the response stream ✅ IMPLEMENTED
23
-
24
- ### Diagnosis
25
-
26
- **File:** `src/runtime/entry-server-template.ts`, line 319
27
-
28
- ```ts
29
- const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })
30
- ```
31
-
32
- `renderToStreamWithJITCSSDSD` constructs a `ReadableStream` whose `start()` callback runs
33
- **synchronously** inside the constructor. If any component render function throws, that
34
- exception propagates out of the constructor and through the `handler` function entirely
35
- unhandled — because lines 290–389 have no top-level `try/catch`. The HTTP response headers
36
- may or may not have been written at that point. In Node.js HTTP servers, an uncaught
37
- exception in a request handler kills the connection immediately. There is no fallback to a
38
- client-side render and no 500 response.
39
-
40
- The async streaming loop (lines 376–380) also lacks error handling:
41
-
42
- ```ts
43
- while (true) {
44
- const { value, done } = await reader.read()
45
- if (done) break
46
- res.write(value)
47
- }
48
- ```
49
-
50
- If `reader.read()` rejects mid-stream (headers already sent), the exception propagates
51
- upward through the ALS chains and crashes the handler with a half-flushed response.
52
-
53
- ### Fix design
54
-
55
- Wrap the synchronous stream construction and the async streaming loop in `try/catch`
56
- blocks inside the entry-server template. On render error:
57
-
58
- 1. **Before headers sent** (`!res.headersSent`): send HTTP 500 with the error page vnode
59
- rendered as a fallback, or a plain HTML error message if the error page itself fails.
60
- 2. **After headers sent** (mid-stream): close the stream cleanly with an inline error
61
- comment and call `res.end()`. The browser will receive a complete but truncated
62
- document; JS hydration will fail gracefully since the error boundary component is
63
- already in the entry chunk.
64
-
65
- Inline error rendering must not call `renderToStreamWithJITCSSDSD` again recursively
66
- (infinite loop risk). Use a synchronous `renderToString` fallback if available, or emit
67
- a minimal HTML error string directly.
68
-
69
- ### Implementation steps
70
-
71
- **Project:** `vite-plugin-cer-app`
72
-
73
- 1. **`src/runtime/entry-server-template.ts`** — wrap the stream construction and read loop:
74
-
75
- ```ts
76
- // Replace line 319 area with:
77
- let stream: ReadableStream<string>
78
- try {
79
- stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })
80
- } catch (renderErr) {
81
- const errMsg = renderErr instanceof Error ? renderErr.message : String(renderErr)
82
- console.error('[cer-app] SSR render error (synchronous):', renderErr)
83
- if (!res.headersSent) {
84
- res.statusCode = 500
85
- const errBody = errorTag
86
- ? `<${errorTag} error=${JSON.stringify(errMsg)}></${errorTag}>`
87
- : `<div style="font-family:monospace;padding:2rem">SSR error: ${errMsg}</div>`
88
- res.setHeader('Content-Type', 'text/html; charset=utf-8')
89
- res.end(`<!DOCTYPE html><html><body>${errBody}</body></html>`)
90
- } else {
91
- res.end()
92
- }
93
- return
94
- }
95
- ```
96
-
97
- Wrap the streaming read loop:
98
- ```ts
99
- try {
100
- while (true) {
101
- const { value, done } = await reader.read()
102
- if (done) break
103
- res.write(value)
104
- }
105
- res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)
106
- } catch (streamErr) {
107
- console.error('[cer-app] SSR stream error (mid-stream):', streamErr)
108
- if (!res.writableEnded) res.end()
109
- }
110
- ```
111
-
112
- 2. **`src/__tests__/plugin/entry-server-template.test.ts`** — add assertions:
113
- - Template contains the synchronous render error catch block
114
- - Template contains the stream loop error catch block
115
-
116
- 3. **`e2e/kitchen-sink/app/pages/`** — add `render-error-test.ts`: a page whose render
117
- function intentionally `throw new Error('render-error-test')` when
118
- `?crash=1` query param is present.
119
-
120
- 4. **`e2e/cypress/e2e/`** — add `render-error.cy.ts`:
121
- - `cy.request('/render-error-test?crash=1')` — assert status 500, body contains error
122
- markup (no connection reset)
123
- - `cy.visit('/render-error-test')` — assert page loads normally (no query param)
124
-
125
- ### Implementation notes
126
-
127
- - Implemented as a single `try/catch (_renderErr)` wrapping the full render + stream block.
128
- - `_headCollectionOpen` flag ensures `endHeadCollection()` is always called to prevent global state leaks.
129
- - Before-headers path: sets `res.statusCode = 500`, sends a complete HTML error page.
130
- - After-headers path: calls `res.end()` to close cleanly.
131
- - Unit tests added to `src/__tests__/plugin/entry-server-template.test.ts`.
132
- - **Important clarification:** The custom-elements runtime already catches *component-level* render errors internally (`ssr-context.ts` `runComponentSSRRender` try/catch). A component throwing during render produces a warning log and an empty DSD placeholder — the server returns 200. The entry-server-template try/catch protects against *infrastructure-level* failures that escape the runtime (e.g., a crash in the SSR helper machinery itself).
133
- - Kitchen-sink page `e2e/kitchen-sink/app/pages/render-error-test.ts` throws unconditionally to verify graceful degradation.
134
- - Cypress spec `e2e/cypress/e2e/ssr-render-error.cy.ts` verifies: HTTP 200 response (runtime catches the component error), valid HTML body, component rendered as empty placeholder, server survives for subsequent requests.
135
-
136
- ---
137
-
138
- ## P0-2 — ISR concurrent revalidation race condition ✅ IMPLEMENTED
139
-
140
- ### Diagnosis
141
-
142
- **File:** `src/runtime/isr-handler.ts`, lines 156–171
143
-
144
- ```ts
145
- if (!cached.revalidating) {
146
- cached.revalidating = true
147
- _serveFromCache(cached, res, 'STALE')
148
- const timeout = setTimeout(() => { if (cached) cached.revalidating = false }, 30_000)
149
- _renderForCache(urlPath, handler, revalidate).then((entry) => {
150
- clearTimeout(timeout)
151
- if (entry) cache.set(urlPath, entry)
152
- else if (cached) cached.revalidating = false
153
- }).catch(() => {
154
- clearTimeout(timeout)
155
- if (cached) cached.revalidating = false
156
- })
157
- return
158
- }
159
- _serveFromCache(cached, res, 'STALE') // line 170
160
- ```
161
-
162
- The `revalidating` boolean is a soft lock. The 30-second `setTimeout` resets it to `false`
163
- even if `_renderForCache` is still running (it has not been cancelled). If the render takes
164
- over 30 seconds (slow page, large SSR), `revalidating` resets to `false`. The next request
165
- sets it to `true` again and starts **a second concurrent render** for the same path. Under
166
- high traffic this can produce many simultaneous background renders for the same URL,
167
- consuming unbounded memory and CPU.
168
-
169
- Additionally, `_renderForCache` is a promise that is not cancelled when a second render
170
- starts. The first render may complete after the second and overwrite the cache with older
171
- content (`cache.set` on line 162 is unconditional on the key).
172
-
173
- ### Fix design
174
-
175
- Replace the boolean flag with a **per-path pending Promise** stored in a `Map`. A path
176
- has at most one in-flight revalidation. New requests for the same stale path while
177
- revalidation is pending are served the stale cached response immediately — no new Promise
178
- is created. When the revalidation resolves or rejects, the pending entry is cleared.
179
-
180
- Remove the `setTimeout` watchdog entirely. Instead, add an optional `revalidateTimeout`
181
- option (default: `30_000` ms) that races with `_renderForCache` using `Promise.race`. If
182
- the render times out, the pending entry is cleared and the stale response continues to be
183
- served on the next request (which will attempt a fresh render).
184
-
185
- The last-writer-wins problem is eliminated because there is only ever one in-flight render
186
- per path.
187
-
188
- ### Implementation steps
189
-
190
- **Project:** `vite-plugin-cer-app`
191
-
192
- 1. **`src/runtime/isr-handler.ts`** — replace the `revalidating` boolean with a pending map:
193
-
194
- ```ts
195
- // Replace: cached.revalidating field entirely
196
- // Add module-level:
197
- const _pending = new Map<string, Promise<void>>()
198
-
199
- // In createIsrHandler, stale branch:
200
- if (!_pending.has(urlPath)) {
201
- const revalidateMs = (options?.revalidateTimeout ?? 30_000)
202
- const renderPromise = Promise.race([
203
- _renderForCache(urlPath, handler, revalidate),
204
- new Promise<null>((_, reject) =>
205
- setTimeout(() => reject(new Error('ISR revalidation timeout')), revalidateMs)
206
- ),
207
- ])
208
- .then((entry) => {
209
- if (entry) cache.set(urlPath, entry)
210
- })
211
- .catch((err) => {
212
- console.warn('[cer-app] ISR revalidation failed for', urlPath, err?.message ?? err)
213
- })
214
- .finally(() => {
215
- _pending.delete(urlPath)
216
- })
217
- _pending.set(urlPath, renderPromise)
218
- }
219
- _serveFromCache(cached, res, 'STALE')
220
- ```
221
-
222
- 2. **`src/runtime/isr-handler.ts`** — remove the `revalidating` field from `CacheEntry`
223
- interface and all references to it.
224
-
225
- 3. **`src/__tests__/runtime/isr-handler.test.ts`** (create if missing) — add tests:
226
- - Two concurrent stale requests → only one `_renderForCache` call made
227
- - Render timeout → pending cleared, next request triggers a new render
228
- - Render success → cache updated, no stale entry served after
229
-
230
- 4. **`e2e/kitchen-sink/`** — existing `isr-nested-runtime.cy.ts` may already cover ISR;
231
- verify it exercises the stale-while-revalidate path.
232
-
233
- ### Implementation notes
234
-
235
- - Replaced `revalidating: boolean` in `IsrCacheEntry` with `Map<string, Promise<void>> _inFlight` inside `createIsrHandler`.
236
- - At most one background render runs per URL path at any time (true lock, not a soft boolean).
237
- - Lock is released via `.finally(() => _inFlight.delete(urlPath))` when the Promise settles — no 30s timer needed.
238
- - The "30s timeout" test was replaced with a "lock released after Promise resolves" test.
239
- - `revalidating` field removed from the `IsrCacheEntry` interface.
240
-
241
- ---
242
-
243
- ## P0-3 — Async SSR components can hang indefinitely ✅ IMPLEMENTED
244
-
245
- ### Diagnosis
246
-
247
- **File:** `custom-elements/src/lib/runtime/` — `renderToStreamWithJITCSSDSD`
248
-
249
- Async components are rendered as placeholder elements during the synchronous first pass;
250
- their resolved content is streamed as swap scripts. If an async component's Promise never
251
- settles (network failure, infinite loop in setup), the `reader.read()` loop in the
252
- entry-server template (lines 376–380) awaits forever. The HTTP connection is left open and
253
- the client spinner never stops. There is no configurable timeout.
254
-
255
- ### Fix design
256
-
257
- Add a `ssrTimeout` option (milliseconds, default `10_000`) to `renderToStreamWithJITCSSDSD`.
258
- Internally, the stream controller should track all pending async component Promises.
259
- When `ssrTimeout` elapses, any remaining pending Promises are forcibly resolved with an
260
- empty swap (the placeholder element stays in the DOM). The stream is then closed normally.
261
-
262
- If `ssrTimeout: 0` is passed, the timeout is disabled (explicit opt-out for known-safe
263
- environments).
264
-
265
- ### Implementation steps
266
-
267
- **Project:** `@jasonshimmy/custom-elements-runtime`
268
-
269
- 1. **`src/lib/runtime/ssr/` (whichever file implements `renderToStreamWithJITCSSDSD`)**
270
- — add `ssrTimeout` parameter and timeout logic:
271
- - Track pending async Promises in a `Set`
272
- - After the synchronous first-pass render, set a `setTimeout` for `ssrTimeout` ms
273
- - When it fires, resolve all remaining pending Promises with empty content and enqueue
274
- the stream close
275
- - Clear the timeout if all Promises resolve naturally before it fires
276
-
277
- 2. **`src/lib/vite-plugin.ts`** — thread `ssrTimeout` through the `CerSSROptions` interface
278
- so it's configurable in `cer.config.ts` under `jitCss.ssr.ssrTimeout`.
279
-
280
- 3. **`src/runtime/entry-server-template.ts`** — pass the configured timeout:
281
- ```ts
282
- const stream = renderToStreamWithJITCSSDSD(vnode, {
283
- dsdPolyfill: false,
284
- router,
285
- ssrTimeout: runtimeConfig.ssrTimeout ?? 10_000,
286
- })
287
- ```
288
-
289
- 4. **Tests (runtime):**
290
- - `test/ssr-async-timeout.spec.ts` — component with a never-settling Promise; assert
291
- stream closes within `ssrTimeout + buffer` ms with placeholder content in output
292
- - `test/ssr-async-timeout.spec.ts` — component that resolves before timeout; assert
293
- resolved content appears and timeout is cleared
294
-
295
- 5. **`e2e/kitchen-sink/app/pages/`** — add `async-component-test.ts` with a slow
296
- async component (resolves after 100 ms) and a never-settling one (guarded by a query
297
- param). E2e: assert the page renders within a reasonable time and does not hang.
298
-
299
- 6. **`docs/configuration.md`** — document `ssrTimeout` option.
300
-
301
- ### Implementation notes
302
-
303
- - Implemented as `asyncTimeout?: number` option (default `30_000` ms) on `renderToStream` and `renderToStreamWithJITCSSDSD` in `custom-elements/src/lib/ssr.ts`.
304
- - Each `await entry.promise` is wrapped with `Promise.race([entry.promise, timeoutPromise])` — timed-out entries leave their placeholders in the DOM for client-side hydration.
305
- - Unit tests in `custom-elements/test/render-to-stream.spec.ts` cover: basic streaming, timeout closes the stream, option threads through `renderToStreamWithJITCSSDSD`, sync render errors.
306
- - Note: The plan proposed `ssrTimeout` as the option name; the implementation uses `asyncTimeout` to better describe what is being timed (the async component Promises, not the overall SSR render).
307
-
308
- ---
309
-
310
- ## P0-4 — Reactive subscriptions leak on component disconnect ✅ IMPLEMENTED
311
-
312
- ### Diagnosis
313
-
314
- **File:** `custom-elements/src/lib/runtime/component/factory.ts`, lines 161–169
315
-
316
- The `onDisconnected` lifecycle hook fires when the component is removed from the DOM.
317
- However, it only calls the **user-provided** cleanup function (`lifecycleHooks.onDisconnected`).
318
- The framework itself does not track or clean up `watch()`, `watchEffect()`, and `computed()`
319
- subscriptions created during the component's render lifecycle. If the user forgets to call
320
- cleanup — which is common, especially in layout components — subscriptions accumulate for
321
- the life of the session. Layouts are particularly high-risk: a `layout-default` component
322
- may be mounted once and never disconnected, accumulating subscriptions on every navigation.
323
-
324
- ### Fix design
325
-
326
- The reactive system already tracks "which component is currently rendering" via
327
- `reactiveSystem.setCurrentComponent(componentId, callback)`. Extend this to also **track
328
- all subscriptions created** while a component is the current component. Store the stop
329
- functions in a per-component registry keyed by `componentId`.
330
-
331
- When `onDisconnected` fires on a component, the framework automatically calls all registered
332
- stop functions for that `componentId` **before** calling the user's hook. This is transparent
333
- to the user and backward compatible.
334
-
335
- `watch`, `watchEffect`, and `computed` must return stop functions (they likely already do —
336
- verify) and register themselves with the reactive system when a `componentId` is active.
337
-
338
- ### Implementation steps
339
-
340
- **Project:** `@jasonshimmy/custom-elements-runtime`
341
-
342
- 1. **`src/lib/runtime/reactive/` (wherever `reactiveSystem` lives)**
343
- — add a `componentSubscriptions: Map<string, Set<() => void>>` registry:
344
- ```ts
345
- function trackSubscription(componentId: string, stop: () => void): void {
346
- if (!componentSubscriptions.has(componentId)) {
347
- componentSubscriptions.set(componentId, new Set())
348
- }
349
- componentSubscriptions.get(componentId)!.add(stop)
350
- }
351
-
352
- function cleanupComponent(componentId: string): void {
353
- const stops = componentSubscriptions.get(componentId)
354
- if (stops) {
355
- for (const stop of stops) stop()
356
- componentSubscriptions.delete(componentId)
357
- }
358
- }
359
- ```
360
-
361
- 2. **`src/lib/runtime/reactive/watch.ts`** (or equivalent) — when `watch()` or
362
- `watchEffect()` is called while a `componentId` is active (i.e., during a render),
363
- register the returned stop function:
364
- ```ts
365
- const stop = _createWatch(...)
366
- const activeId = reactiveSystem.getCurrentComponent()
367
- if (activeId) trackSubscription(activeId, stop)
368
- return stop
369
- ```
370
-
371
- 3. **`src/lib/runtime/component/factory.ts`, `onDisconnected` path (line 161)**
372
- — call `cleanupComponent(componentId)` before the user's hook:
373
- ```ts
374
- onDisconnected: (context) => {
375
- cleanupComponent((context as InternalContext)._componentId)
376
- if (lifecycleHooks.onDisconnected) { ... }
377
- }
378
- ```
379
-
380
- 4. **`src/lib/runtime/reactive/computed.ts`** — same registration for computed
381
- dependencies if computed tracks subscriptions internally.
382
-
383
- 5. **Tests:**
384
- - `test/subscription-cleanup.spec.ts` — mount a component with `watch()`, disconnect
385
- it, assert the watch callback is never called after disconnect
386
- - `test/subscription-cleanup.spec.ts` — mount, reconnect, remount; assert subscriptions
387
- are re-registered correctly
388
- - `test/subscription-cleanup.spec.ts` — layout component (never disconnected); assert
389
- subscriptions are still live after a simulated navigation
390
-
391
- 6. **`docs/components.md`** — add a note that manual cleanup in `onDisconnected` is no
392
- longer required for `watch`, `watchEffect`, and `computed` created during render.
393
-
394
- ### Implementation notes
395
-
396
- - Fixed in `custom-elements/src/lib/runtime/reactive.ts` `cleanup()` method.
397
- - Before deleting the component's `componentData` entry, iterates `data.watchers` and calls `this.cleanup(wid)` recursively for each — identical to the pattern already used in `setCurrentComponent()` for re-renders.
398
- - This means `watch()`, `watchEffect()`, and `computed()` created during render are automatically unsubscribed when the component disconnects, without requiring manual `useOnDisconnected(stop)` calls.
399
- - Unit tests in `custom-elements/test/reactive-cleanup-cascade.spec.ts`.
400
- - Note: The plan described a more complex "subscription registry" approach. The actual fix leverages the existing `data.watchers` map that `registerWatcher()` already populates — no new data structure needed.
401
-
402
- ---
403
-
404
- ## P1-1 — No 404 fallback when catch-all route is absent ✅ IMPLEMENTED
405
-
406
- ### Diagnosis
407
-
408
- **File:** `src/plugin/virtual/routes.ts`, lines 197–202
409
-
410
- The framework converts `app/pages/404.ts` to a `/:all*` catch-all route. If neither
411
- `404.ts` nor a user-defined `[...all].ts` exists, no catch-all is generated. The router's
412
- `matchRoute()` returns `null` for any unrecognized path. The behavior is undefined: likely
413
- a blank page or a thrown exception inside `_prepareRequest`.
414
-
415
- ### Fix design
416
-
417
- In `generateRoutesCode`, after the deduplication and sort step, check whether any route
418
- with `routePath: '/:all*'` exists. If not, append a minimal generated catch-all that
419
- renders nothing but returns HTTP 404:
420
-
421
- ```ts
422
- // Append synthesized catch-all if none exists
423
- if (!sorted.some(e => e.isCatchAll)) {
424
- items.push(` {\n path: '/:all*',\n load: () => Promise.resolve({ default: null, loader: null }),\n meta: { render: 'ssr' }\n }`)
425
- }
426
- ```
427
-
428
- The `null` tag name must be handled in `_prepareRequest` in the entry-server template:
429
- if `mod.default` is `null`, skip rendering and return `{ status: 404, vnode: errorVnode }`.
430
-
431
- ### Implementation steps
432
-
433
- **Project:** `vite-plugin-cer-app`
434
-
435
- 1. **`src/plugin/virtual/routes.ts`** — after `sortRoutes`, add the synthetic catch-all
436
- guard as described above.
437
-
438
- 2. **`src/runtime/entry-server-template.ts`**, `_prepareRequest` — add null tag handling:
439
- ```ts
440
- if (!mod.default) {
441
- // No page component — return 404
442
- const notFoundVnode = errorTag
443
- ? { tag: errorTag, props: { attrs: { error: 'Not Found', status: '404' } }, children: [] }
444
- : { tag: 'div', props: {}, children: 'Not Found' }
445
- return { vnode: notFoundVnode, router, head: '', status: 404 }
446
- }
447
- ```
448
-
449
- 3. **`src/__tests__/plugin/virtual/routes.test.ts`** — add test: when no catch-all page
450
- exists in the pages directory, the generated routes code contains `'/:all*'`.
451
-
452
- 4. **`src/__tests__/plugin/entry-server-template.test.ts`** — add assertion: template
453
- handles `mod.default === null` with a 404 status return.
454
-
455
- ### Implementation notes
456
-
457
- - `src/plugin/virtual/routes.ts`: After `sortRoutes`, if no `/:all*` catch-all exists, a synthetic route is appended: `load: () => Promise.resolve({ default: null, loader: null })`.
458
- - `src/runtime/entry-server-template.ts`: Two complementary fixes handle HTTP 404 for all catch-all cases:
459
- 1. `if (!pageTag)` — synthetic route (`mod.default === null`): renders the per-route or global `errorTag` with `status: 404`.
460
- 2. `const isCatchAll = route?.path === '/:all*'` → `status: isCatchAll ? 404 : null` — user-defined `404.ts` or `[...all].ts`: the real page component renders but the HTTP response code is 404.
461
- - Unit tests in `src/__tests__/plugin/entry-server-template.test.ts` and `src/__tests__/plugin/virtual/routes.test.ts`.
462
- - Cypress spec `e2e/cypress/e2e/synthetic-404.cy.ts` verifies all three behaviors: client-side navigation, HTTP 404 status, and no HTTP 500.
463
-
464
- ---
465
-
466
- ## P1-2 — Server middleware cannot return status codes other than 500 ✅ IMPLEMENTED
467
-
468
- ### Diagnosis
469
-
470
- **File:** `src/runtime/entry-server-template.ts` (the `runServerMiddleware` template body)
471
-
472
- The middleware error branch always writes `res.statusCode = 500`. Middleware that wants to
473
- reject with 401 (unauthorized) or 403 (forbidden) must manually write the full response
474
- and call `res.end()` before returning — there is no structured way to signal a specific
475
- HTTP status from a middleware throw.
476
-
477
- ### Fix design
478
-
479
- Inspect the thrown error for a `status` or `statusCode` numeric property (same pattern
480
- used by `_prepareRequest` for loader errors). If found, use that value; otherwise fall back
481
- to 500.
482
-
483
- ```ts
484
- } catch (err: unknown) {
485
- if (!res.writableEnded) {
486
- const status = typeof err === 'object' && err !== null && 'status' in err
487
- ? Number((err as { status: unknown }).status)
488
- : 500
489
- res.statusCode = isNaN(status) ? 500 : status
490
- res.end('Internal Server Error')
491
- }
492
- return false
493
- }
494
- ```
495
-
496
- Document that middleware can do `throw { status: 401, message: 'Unauthorized' }` to
497
- produce a non-500 response.
498
-
499
- ### Implementation steps
500
-
501
- **Project:** `vite-plugin-cer-app`
502
-
503
- 1. **`src/runtime/entry-server-template.ts`** — update the server middleware error catch
504
- block as described.
505
-
506
- 2. **`src/__tests__/plugin/entry-server-template.test.ts`** — add assertions:
507
- - Template inspects `err.status` for non-500 values in server middleware catch block
508
-
509
- 3. **`docs/middleware.md`** — document `throw { status: 401 }` pattern.
510
-
511
- ---
512
-
513
- ## P1-3 — Session secret is not rotatable ✅ IMPLEMENTED
514
-
515
- ### Diagnosis
516
-
517
- **File:** `src/runtime/composables/use-session.ts`, lines 124–138
518
-
519
- `_getSecret()` reads a single string from `runtimeConfig.private.sessionSecret`. The
520
- HMAC key is derived from this string on every sign/verify call. If the secret is changed
521
- (periodic rotation, leak remediation), all existing session cookies fail signature
522
- verification and every signed-in user is immediately logged out.
523
-
524
- ### Fix design
525
-
526
- Support `sessionSecret` as either a `string` (existing behavior) or an `array of strings`.
527
- When validating an incoming cookie, try each key in order. When signing a new cookie,
528
- always use the **first** key (the active key). Old sessions signed with any of the other
529
- keys are still accepted until they expire, giving users a graceful rotation window.
530
-
531
- ```ts
532
- // cer.config.ts / runtimeConfig.private
533
- sessionSecret: process.env.SESSION_SECRET // existing: single string
534
- sessionSecret: [
535
- process.env.SESSION_SECRET_NEW, // active key (signs new sessions)
536
- process.env.SESSION_SECRET_OLD, // accepted for validation only
537
- ]
538
- ```
539
-
540
- ### Implementation steps
541
-
542
- **Project:** `vite-plugin-cer-app`
543
-
544
- 1. **`src/types/config.ts`** — update `RuntimePrivateConfig.sessionSecret` type:
545
- ```ts
546
- sessionSecret?: string | string[]
547
- ```
548
-
549
- 2. **`src/runtime/composables/use-session.ts`**:
550
- - `_getSecret()` → `_getSecrets(): string[]` — always returns an array (single string
551
- is wrapped in `[secret]`)
552
- - `set()`: signs with `secrets[0]`
553
- - `get()`: tries each key in order; returns data from the first that verifies
554
-
555
- 3. **`src/__tests__/runtime/use-session.test.ts`** — add tests:
556
- - Array of two secrets: cookie signed with second key is still accepted
557
- - Array of two secrets: new cookies are signed with the first key
558
- - Secret rotation: old cookie accepted with old secret in array; rejected after
559
- old secret removed from array
560
-
561
- 4. **`docs/configuration.md`** — document array form under `runtimeConfig.private.sessionSecret`.
562
-
563
- ---
564
-
565
- ## P1-4 — Cloudflare adapter has no size check for inlined HTML ✅ IMPLEMENTED
566
-
567
- ### Diagnosis
568
-
569
- **File:** `src/cli/adapters/cloudflare.ts`, line 58–68
570
-
571
- The entire contents of `dist/client/index.html` are inlined as a template literal string
572
- constant inside `_worker.js`. Cloudflare Workers have a **1 MB compressed script size
573
- limit** (Free plan) or **10 MB** (Paid plan). A large HTML template — one with many
574
- inlined scripts, large DSD payloads, or bulky meta tags — can push the worker file over
575
- this limit, causing deployment to fail with a cryptic Wrangler error.
576
-
577
- ### Fix design
578
-
579
- After generating `_worker.js`, measure its byte length. If it exceeds 900 KB (conservative
580
- Free plan limit), print a clear warning with the measured size and the Cloudflare limit.
581
- If it exceeds 9 MB (conservative Paid plan limit), print an error and exit with a non-zero
582
- code.
583
-
584
- Thresholds should be configurable via `adapter: { name: 'cloudflare', warnSize: ..., errorSize: ... }`.
585
-
586
- ### Implementation steps
587
-
588
- **Project:** `vite-plugin-cer-app`
589
-
590
- 1. **`src/cli/adapters/cloudflare.ts`** — after writing `_worker.js`, check file size:
591
-
592
- ```ts
593
- const workerPath = join(outputDir, '_worker.js')
594
- const sizeBytes = statSync(workerPath).size
595
- const warnLimit = options?.warnSize ?? 900_000
596
- const errorLimit = options?.errorSize ?? 9_000_000
597
- if (sizeBytes > errorLimit) {
598
- console.error(`[cer-app] Cloudflare _worker.js is ${(sizeBytes / 1e6).toFixed(1)} MB — exceeds the ${(errorLimit / 1e6).toFixed(0)} MB limit. Build will likely fail to deploy.`)
599
- process.exit(1)
600
- } else if (sizeBytes > warnLimit) {
601
- console.warn(`[cer-app] Cloudflare _worker.js is ${(sizeBytes / 1e3).toFixed(0)} KB — approaching the Cloudflare Free plan 1 MB limit.`)
602
- }
603
- ```
604
-
605
- 2. **`src/types/config.ts`** — add optional `CloudflareAdapterOptions` type and thread it
606
- through `CerAppConfig`.
607
-
608
- 3. **`src/__tests__/cli/adapters/cloudflare.test.ts`** — add test: mock `statSync` to
609
- return a large size; assert the warning/error message is emitted.
610
-
611
- 4. **`docs/configuration.md`** — document `adapter` options for Cloudflare including
612
- `warnSize` and `errorSize`.
613
-
614
- ---
615
-
616
- ## P1-5 — Auto-import injects full import groups; unused exports cannot be tree-shaken ✅ IMPLEMENTED
617
-
618
- ### Diagnosis
619
-
620
- **File:** `src/plugin/transforms/auto-import.ts`, lines 90–113
621
-
622
- `isFrameworkImportNeeded` returns `true` if any single identifier from `FRAMEWORK_IDENTIFIERS`
623
- is found in the file. When it does, the entire `FRAMEWORK_IMPORTS` string is prepended —
624
- all 15 composables in one import statement. A page that uses only `useHead` gets:
625
-
626
- ```ts
627
- import { useHead, usePageData, useInject, useRuntimeConfig, defineMiddleware,
628
- defineServerMiddleware, useSeoMeta, useCookie, useSession, useAuth,
629
- useFetch, useRoute, navigateTo, useState, useLocale }
630
- from '@jasonshimmy/vite-plugin-cer-app/composables'
631
- ```
632
-
633
- Rollup cannot tree-shake named imports from packages whose `sideEffects` is not `false`.
634
- Both packages declare `sideEffects: ["**/*.css"]`, which means Rollup must conservatively
635
- treat all non-CSS files as having side effects. The unused 14 composables ship in every
636
- page chunk that uses even one framework composable.
637
-
638
- The same problem applies to `RUNTIME_IMPORTS` (29 runtime exports for any page using
639
- even `html`) and `DIRECTIVE_IMPORTS` (4 directive exports for any page using `when`).
640
-
641
- ### Fix design
642
-
643
- Replace the three monolithic import strings with per-identifier injection. `FRAMEWORK_IMPORTS`,
644
- `RUNTIME_IMPORTS`, and `DIRECTIVE_IMPORTS` become maps of identifier → source module path.
645
- The `autoImportTransform` function builds the minimum import statement containing only the
646
- identifiers actually referenced in the file.
647
-
648
- ```ts
649
- const RUNTIME_MAP: Record<string, string> = {
650
- component: '@jasonshimmy/custom-elements-runtime',
651
- html: '@jasonshimmy/custom-elements-runtime',
652
- ref: '@jasonshimmy/custom-elements-runtime',
653
- // ...
654
- }
655
- const DIRECTIVE_MAP: Record<string, string> = {
656
- when: '@jasonshimmy/custom-elements-runtime/directives',
657
- each: '@jasonshimmy/custom-elements-runtime/directives',
658
- // ...
659
- }
660
- const FRAMEWORK_MAP: Record<string, string> = {
661
- useHead: '@jasonshimmy/vite-plugin-cer-app/composables',
662
- useState: '@jasonshimmy/vite-plugin-cer-app/composables',
663
- // ...
664
- }
665
- ```
666
-
667
- Group by source path before emitting so that `import { useHead, useState } from '...'` is
668
- a single statement rather than two. This preserves the existing injection shape for pages
669
- that use many identifiers.
670
-
671
- The change is backward compatible: the injected code produces identical named imports from
672
- the same module paths. Only the subset changes.
673
-
674
- ### Implementation steps
675
-
676
- **Project:** `vite-plugin-cer-app`
677
-
678
- 1. **`src/plugin/transforms/auto-import.ts`** — replace the three string constants and
679
- their `isXImportNeeded` + injection pattern with map-based per-identifier injection.
680
- Keep the existing duplicate-import guard (check if already importing from the source
681
- path before injecting).
682
-
683
- 2. **`src/__tests__/plugin/transforms/auto-import.test.ts`** — update all tests that
684
- check the injected import string to match the new per-identifier form:
685
- - Page using only `html` → injects only `{ html }` from runtime, not all 29
686
- - Page using `useHead` + `useState` → injects `{ useHead, useState }` from composables,
687
- not all 15
688
- - Page already importing `{ html }` → no duplicate injection
689
- - Page using `when` → injects only `{ when }` from directives
690
- - Page using no recognized identifier → returns null
691
-
692
- 3. **`docs/configuration.md`** — update the auto-imports section to note that only used
693
- identifiers are injected.
694
-
695
- ---
696
-
697
- ## P2-1 — Nested routes ✅ IMPLEMENTED
698
-
699
- ### Diagnosis
700
-
701
- The router ([`virtual/routes.ts`](../src/plugin/virtual/routes.ts)) generates a flat route
702
- array. The `layoutChain` meta property is a workaround for layout nesting but it is not
703
- true nested routing — child routes cannot define their own data loaders that compose with
704
- parent loaders, and there is no shared parent URL segment prefix enforcement.
705
-
706
- ### Fix design
707
-
708
- Adopt the `_layout.ts` convention already partially present in the codebase
709
- (`app/pages/admin/_layout.ts` is excluded from page scanning at
710
- [`virtual/routes.ts:189`](../src/plugin/virtual/routes.ts)). Extend this to make
711
- `_layout.ts` files act as **route group wrappers** that set shared `meta` (layout,
712
- middleware, prefix) for all routes in the same directory.
713
-
714
- A route group directory (`app/pages/admin/`) with a `_layout.ts` that exports:
715
- ```ts
716
- export const meta = { layout: 'admin', middleware: ['requireAuth'] }
717
- ```
718
- would automatically apply those meta fields to all routes in `app/pages/admin/**/*.ts`
719
- without repeating them on each page.
720
-
721
- This is additive only — no behavior changes for existing pages. True deeply nested router
722
- rendering (React Router / Nuxt nested views) is deferred as a larger scope change; the
723
- immediate goal is shared meta inheritance.
724
-
725
- ### Implementation steps
726
-
727
- **Project:** `vite-plugin-cer-app`
728
-
729
- 1. **`src/plugin/virtual/routes.ts`**:
730
- - After scanning pages, scan for `_layout.ts` files in any subdirectory
731
- - Import each `_layout.ts` at build time (via `readFileSync` + regex extraction or
732
- a Vite `load` call) to extract the exported `meta` object
733
- - Merge that meta into every route whose `filePath` is under that directory
734
- - Directory meta has lower precedence than page-level meta (page can override)
735
-
736
- 2. **`src/__tests__/plugin/virtual/routes.test.ts`** — add tests:
737
- - `admin/_layout.ts` with `middleware: ['requireAuth']` → all `admin/**` routes
738
- inherit `middleware`
739
- - Page-level `meta.middleware` overrides inherited value
740
- - No `_layout.ts` → no change to existing behavior
741
-
742
- 3. **`e2e/kitchen-sink/app/pages/admin/`** — already has `_layout.ts` and `dashboard.ts`;
743
- update to verify meta inheritance in `e2e/cypress/e2e/routes.cy.ts`.
744
-
745
- 4. **`docs/routing.md`** — document `_layout.ts` meta inheritance.
746
-
747
- ---
748
-
749
- ## P2-2 — Per-route error components ✅ IMPLEMENTED
750
-
751
- ### Diagnosis
752
-
753
- `app/error.ts` is the single global error boundary. A 404 page uses the same error
754
- component as a database crash on the admin dashboard. Nuxt and Next.js support
755
- per-segment `error.vue` / `error.tsx` files.
756
-
757
- ### Fix design
758
-
759
- Support an `app/pages/[route].error.ts` convention (co-located error component):
760
-
761
- - `app/pages/admin/dashboard.error.ts` → used as the error boundary for the
762
- `/admin/dashboard` route only
763
- - `app/pages/admin/_error.ts` → used as the error boundary for all routes in
764
- `app/pages/admin/**`
765
- - `app/error.ts` → global fallback (existing behavior)
766
-
767
- The error component resolution priority: co-located `.error.ts` > directory `_error.ts` >
768
- global `app/error.ts`.
769
-
770
- The route `meta` object gains an `errorTag` field that the entry-server template and the
771
- client `cer-layout-view` component consult when displaying an error boundary.
772
-
773
- ### Implementation steps
774
-
775
- **Project:** `vite-plugin-cer-app`
776
-
777
- 1. **`src/plugin/virtual/routes.ts`** — during route building, check for a co-located
778
- `*.error.ts` or a directory-level `_error.ts`. If found, import the file, extract the
779
- component tag, and add `errorTag: 'page-admin-dashboard-error'` to the route meta.
780
-
781
- 2. **`src/runtime/entry-server-template.ts`** (`_prepareRequest`) — prefer route-level
782
- `routeMeta?.errorTag` over the global `errorTag` when rendering loader errors.
783
-
784
- 3. **`src/runtime/app-template.ts`** (`cer-layout-view` render function) — prefer
785
- `routeMeta?.errorTag` over the global `errorTag` for client-side error rendering.
786
-
787
- 4. **`src/plugin/virtual/error.ts`** — update to also expose a per-route lookup API
788
- alongside the current boolean/string exports.
789
-
790
- 5. **`src/__tests__/plugin/virtual/routes.test.ts`** — test that co-located `.error.ts`
791
- produces correct `errorTag` in route meta.
792
-
793
- 6. **`docs/routing.md`** — document co-located error components.
794
-
795
- ### Implementation notes
796
-
797
- - `src/plugin/virtual/routes.ts`: `resolveRouteErrorComponent()` checks for a co-located `*.error.ts` first, then a directory-level `_error.ts`. The resolved tag name is stored in `route.meta.errorTag` and the file is bundled alongside the page via the `load()` function.
798
- - `src/runtime/entry-server-template.ts` (`_prepareRequest`): Uses `route?.meta?.errorTag ?? errorTag` as `effectiveErrorTag` when a loader throws, giving per-route components priority over the global error boundary.
799
- - `src/runtime/app-template.ts` (`cer-layout-view`): Computes `matched`/`routeMeta` before the `currentError` check so that `routeMeta?.errorTag ?? (hasError ? errorTag : null)` can be used as `effectiveErrorTag` for client-side errors — mirrors the server-side priority.
800
- - Kitchen-sink fixtures: `e2e/kitchen-sink/app/pages/per-route-error-test.ts` (throws in loader) and `e2e/kitchen-sink/app/pages/per-route-error-test.error.ts` (co-located error component).
801
- - Cypress spec `e2e/cypress/e2e/per-route-error.cy.ts` verifies the per-route error component renders (not the global one) in both SPA and SSR modes.
802
-
803
- ---
804
-
805
- ## P2-3 — Client-side `useFetch()` does not deduplicate concurrent calls ✅ IMPLEMENTED
806
-
807
- ### Diagnosis
808
-
809
- **File:** `src/runtime/composables/use-fetch.ts`
810
-
811
- Server-side: `useFetch` deduplicates via the per-request `_cerFetchStore` map. If
812
- the same key is fetched twice inside one loader, only one network request is made.
813
-
814
- Client-side: when a component mounts and calls `useFetch('/api/posts')`, and another
815
- component on the same page also calls `useFetch('/api/posts')`, two identical network
816
- requests are issued concurrently. There is no in-flight deduplication.
817
-
818
- ### Fix design
819
-
820
- Add a module-level `_inflight: Map<string, Promise<unknown>>` on the client. When
821
- `refresh()` is called:
822
-
823
- 1. If a key is already in `_inflight`, await the existing Promise rather than issuing a
824
- new fetch.
825
- 2. Remove the key from `_inflight` when the Promise settles (both resolve and reject).
826
-
827
- This is a lightweight version of React Query's deduplication — no persistence, no
828
- background refetch coordination, just in-flight deduplication within a single render cycle.
829
-
830
- The map is module-level (not per-component), so two components fetching the same URL
831
- concurrently share one request.
832
-
833
- ### Implementation steps
834
-
835
- **Project:** `vite-plugin-cer-app`
836
-
837
- 1. **`src/runtime/composables/use-fetch.ts`** — add client deduplication:
838
- ```ts
839
- const _inflight = new Map<string, Promise<unknown>>()
840
-
841
- // Inside the client refresh() function:
842
- if (_inflight.has(key)) {
843
- return _inflight.get(key)!.then(/* update reactive state */)
844
- }
845
- const promise = _fetchData(url, fetchOptions).finally(() => _inflight.delete(key))
846
- _inflight.set(key, promise)
847
- ```
848
-
849
- 2. **`src/__tests__/runtime/use-fetch-component.test.ts`** — add tests:
850
- - Two concurrent `refresh()` calls with the same key → only one fetch issued
851
- - Deduplication resolves both callers with the same data
852
- - After resolution, `_inflight` is cleared (next call issues a new fetch)
853
-
854
- 3. **`docs/data-loading.md`** (or equivalent) — document client deduplication behavior.
855
-
856
- ---
857
-
858
- ## P2-4 — No lazy-loaded component support ✅ IMPLEMENTED
859
-
860
- ### Diagnosis
861
-
862
- All component registration calls (`component('ks-badge', renderFn)`) are eager and
863
- synchronous. There is no equivalent of Vue's `defineAsyncComponent` or React's
864
- `React.lazy()` — no way to split a component's implementation into a separate chunk that
865
- loads on first render rather than at page load.
866
-
867
- This is distinct from per-page code splitting (which is implemented): the gap is
868
- **within-page** lazy loading of heavy components (e.g., a rich text editor, a chart
869
- library, a code editor) that may not always render on first paint.
870
-
871
- ### Fix design
872
-
873
- Add `defineAsyncComponent(loader: () => Promise<RenderFunction>)` to the runtime:
874
-
875
- ```ts
876
- // app/components/heavy-editor.ts
877
- export default defineAsyncComponent(() =>
878
- import('./heavy-editor-impl.ts').then(m => m.default)
879
- )
880
-
881
- // Usage in a page:
882
- component('my-page', () => {
883
- return html`<heavy-editor></heavy-editor>`
884
- })
885
- ```
886
-
887
- Internally, `defineAsyncComponent` returns a render function that:
888
- 1. On first render: returns a placeholder (empty or user-specified `loading` slot)
889
- 2. Triggers the `loader()` Promise
890
- 3. When the Promise resolves: registers the real component, requests a re-render
891
-
892
- The framework already handles async component streaming in SSR (swap scripts). The client
893
- path needs to wire into the existing on-demand registration mechanism.
894
-
895
- ### Implementation steps
896
-
897
- **Project:** `@jasonshimmy/custom-elements-runtime`
898
-
899
- 1. **`src/lib/runtime/component/async-component.ts`** (new file):
900
- - `defineAsyncComponent(loader, options?)` function
901
- - Options: `loading?: RenderFunction`, `error?: RenderFunction`, `timeout?: number`
902
- - Internal state machine: `idle → loading → resolved | error | timeout`
903
-
904
- 2. **`src/lib/runtime/component/factory.ts`** — detect when the render function is
905
- an async-component wrapper and handle the loading/resolved states in the render pipeline.
906
-
907
- 3. **`src/lib/index.ts`** — export `defineAsyncComponent`.
908
-
909
- 4. **`src/lib/vite-plugin.ts`** — `extractComponentRegistrations` must recognize
910
- `defineAsyncComponent` calls in addition to `component()` calls so the component
911
- manifest is built correctly.
912
-
913
- 5. **Tests:**
914
- - `test/async-component.spec.ts`:
915
- - Renders placeholder on first mount
916
- - Renders resolved content after loader settles
917
- - Renders error component on loader rejection (when error option provided)
918
- - Timeout option: renders error state if loader takes too long
919
-
920
- 6. **`docs/components.md`** — document `defineAsyncComponent`.
921
-
922
- ---
923
-
924
- ## P2-5 — `adoptedStyleSheets` not used; component styles are embedded `<style>` tags ✅ IMPLEMENTED
925
-
926
- ### Diagnosis
927
-
928
- The SSR DSD output embeds `<style>` inside each `<template shadowrootmode="open">` block.
929
- On the client, when a component upgrades its shadow root from DSD, the styles are already
930
- inline. The framework does not use `adoptedStyleSheets` (the `CSSStyleSheet` API) for
931
- any component styles.
932
-
933
- The gap vs. Nuxt/Next.js is that `adoptedStyleSheets` allows **style deduplication**:
934
- if 50 instances of `<ks-badge>` are on the page, there is currently one `<style>` block
935
- per shadow root (50 total). With `adoptedStyleSheets`, a single `CSSStyleSheet` object
936
- is constructed once and shared across all 50 instances via `shadowRoot.adoptedStyleSheets`.
937
-
938
- This is a performance optimization for pages with many repeated component instances.
939
- It does not affect correctness.
940
-
941
- ### Fix design
942
-
943
- After client-side hydration, the `component()` factory can detect whether a component
944
- uses a CSS block from the `css\`` helper and, if `CSSStyleSheet` is supported (Chrome 73+,
945
- Firefox 101+, Safari 16.4+), adopt a shared stylesheet instead of per-instance `<style>`.
946
-
947
- SSR output remains unchanged (DSD requires inline `<style>`). The optimization is purely
948
- client-side post-hydration.
949
-
950
- Since `adoptedStyleSheets` is broadly but not universally supported, the implementation
951
- must gate on `typeof CSSStyleSheet !== 'undefined' && 'replace' in CSSStyleSheet.prototype`.
952
-
953
- ### Implementation steps
954
-
955
- **Project:** `@jasonshimmy/custom-elements-runtime`
956
-
957
- 1. **`src/lib/runtime/component/factory.ts`** — in the shadow root setup path (post-DSD
958
- upgrade), detect CSS content and, when `CSSStyleSheet` is supported:
959
- ```ts
960
- const sheet = new CSSStyleSheet()
961
- sheet.replaceSync(cssContent)
962
- shadowRoot.adoptedStyleSheets = [sheet]
963
- // Remove the inline <style> block from the shadow root DOM
964
- shadowRoot.querySelector('style')?.remove()
965
- ```
966
- Store sheets in a `Map<tagName, CSSStyleSheet>` at module level for reuse.
967
-
968
- 2. **`src/lib/runtime/ssr/` (DSD serializer)** — no change; SSR still emits inline
969
- `<style>` as required by the DSD specification.
970
-
971
- 3. **Tests:**
972
- - `test/adopted-stylesheets.spec.ts` (JSDOM does not support `adoptedStyleSheets`
973
- fully; use a mock or skip when unsupported):
974
- - Two instances of the same component share the same `CSSStyleSheet` object
975
- - Graceful fallback: when `CSSStyleSheet.prototype.replace` is absent, inline
976
- `<style>` is retained
977
-
978
- 4. **`docs/components.md`** — add a note about `adoptedStyleSheets` optimization.
979
-
980
- ---
981
-
982
- ## Summary
983
-
984
- | # | Priority | Issue | Project |
985
- |---|---|---|---|
986
- | P0-1 | Critical | SSR render errors crash the response stream | vite-plugin-cer-app |
987
- | P0-2 | Critical | ISR concurrent revalidation race condition | vite-plugin-cer-app |
988
- | P0-3 | Critical | Async SSR components can hang indefinitely | custom-elements-runtime |
989
- | P0-4 | Critical | Reactive subscriptions leak on component disconnect | custom-elements-runtime |
990
- | P1-1 | Minor | No 404 fallback when catch-all route is absent | vite-plugin-cer-app |
991
- | P1-2 | Minor | Server middleware limited to status 500 | vite-plugin-cer-app |
992
- | P1-3 | Minor | Session secret is not rotatable | vite-plugin-cer-app |
993
- | P1-4 | Minor | Cloudflare adapter has no size check for inlined HTML | vite-plugin-cer-app |
994
- | P1-5 | Minor | Auto-import injects full import groups | vite-plugin-cer-app |
995
- | P2-1 | Feature | Nested routes / meta inheritance via `_layout.ts` | vite-plugin-cer-app |
996
- | P2-2 | Feature | Per-route error components | vite-plugin-cer-app |
997
- | P2-3 | Feature | Client-side `useFetch()` deduplication | vite-plugin-cer-app |
998
- | P2-4 | Feature | Lazy-loaded components (`defineAsyncComponent`) | custom-elements-runtime |
999
- | P2-5 | Feature | `adoptedStyleSheets` for style deduplication | custom-elements-runtime |
1000
-
1001
- ---
1002
-
1003
- ## Non-goals
1004
-
1005
- The following ❌ items from the audit comparison table are explicitly deferred:
1006
-
1007
- - **Partial Prerendering (PPR)**: Requires Server Components, a different rendering model.
1008
- - **Parallel routes**: Niche use case; `layoutChain` covers most real needs.
1009
- - **Server Components (RSC)**: Fundamentally incompatible with the Web Components model.
1010
- - **Module ecosystem / Devtools**: Community and tooling; not implementable in this codebase.