@webjsdev/cli 0.10.55 → 0.10.57
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.
- package/lib/create.js +43 -1
- package/lib/doctor/codes.js +67 -0
- package/lib/doctor/manifest.js +161 -0
- package/lib/doctor/policy.js +124 -0
- package/lib/doctor/probes/elision.js +111 -0
- package/lib/doctor/probes/env.js +53 -0
- package/lib/doctor/probes/framework-resolves.js +260 -0
- package/lib/doctor/probes/git-hook.js +58 -0
- package/lib/doctor/probes/importmap-coherence.js +158 -0
- package/lib/doctor/probes/node.js +37 -0
- package/lib/doctor/probes/static-asset-freshness.js +58 -0
- package/lib/doctor/probes/tsconfig.js +55 -0
- package/lib/doctor/probes/unmarked-asset-links.js +199 -0
- package/lib/doctor/probes/vendor-gitignore.js +84 -0
- package/lib/doctor/probes/vendor-pin.js +77 -0
- package/lib/doctor/probes/webjs-versions.js +85 -0
- package/lib/doctor/route-modules.js +100 -0
- package/lib/doctor/runner.js +72 -0
- package/lib/doctor/util.js +160 -0
- package/lib/doctor.js +5 -1634
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +4 -3
- package/templates/.agents/skills/webjs/SKILL.md +46 -4
- package/templates/.agents/skills/webjs/references/built-ins.md +3 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +52 -5
- package/templates/.agents/skills/webjs/references/components.md +50 -2
- package/templates/.agents/skills/webjs/references/module-structure.md +229 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +27 -3
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +23 -11
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +36 -2
- package/templates/.agents/skills/webjs/references/runtime.md +6 -1
- package/templates/.agents/skills/webjs/references/styling.md +47 -2
- package/templates/.github/pull_request_template.md +0 -4
- package/templates/gallery/app/features/boundaries/page.ts +11 -0
- package/templates/gallery/app/features/client-router/page.ts +13 -2
- package/templates/gallery/app/features/metadata/page.ts +7 -1
- package/templates/gallery/modules/client-router/components/router-controls.ts +23 -2
- package/templates/gallery/modules/gallery/nav.ts +35 -26
- package/templates/gallery/modules/stream/components/browser/stream-demo.test.js +64 -0
- package/templates/gallery/modules/stream/components/stream-demo.ts +14 -9
- package/templates/gallery/modules/stream/utils/ui/row.ts +41 -0
- package/templates/gallery/test/rate-limit/rate-limit.test.ts +2 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
5
|
- The Next.js patterns that LOOK right in WebJs but break, because WebJs borrows Next's file-based routing shape but not its execution model (no RSC, no `'use client'` split): `redirect()` in a route handler, `fetch()` in a page, `<Link>`, `NEXT_PUBLIC_`, `await params`.
|
|
6
|
-
- The Lit patterns that break WebJs SSR or
|
|
6
|
+
- The Lit patterns that break WebJs SSR, reactivity, or event handling, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, passing a method straight to an `@event` binding, interpolation into `<style>`, reading `assignedNodes()` in `firstUpdated` of a light-DOM component.
|
|
7
7
|
- The WebJs-shaped fix for each, with short code.
|
|
8
8
|
|
|
9
9
|
Read this when a pattern feels familiar from Next.js or Lit but you are not sure it transfers. For the component runtime see `components.md`; for the routing surface see `routing-and-pages.md`. The one difference underneath everything: pages and layouts render server-only and never hydrate, and the one client boundary is a `WebComponent` custom element.
|
|
@@ -202,7 +202,7 @@ Export `GET` / `POST` / etc. as named async functions `(request, { params }) =>
|
|
|
202
202
|
|
|
203
203
|
### `middleware.ts` is per-segment and chainable, not one matcher config
|
|
204
204
|
|
|
205
|
-
The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middleware is in-process, chainable, and per-segment (the Remix / Koa model). There is no `export const config = { matcher }` and no single-file restriction. The default export is `async (req, next) => Response`: return a Response to short-circuit, or call `next()` and post-process. Colocate `app/admin/middleware.ts` next to the admin routes and it runs for that subtree only. An optional root `middleware.ts` runs on every request, outermost to innermost.
|
|
205
|
+
The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middleware is in-process, chainable, and per-segment (the Remix / Koa model). There is no `export const config = { matcher }` and no single-file restriction. The default export is `async (req, next) => Response`: return a Response to short-circuit, or call `next()` and post-process. Colocate `app/admin/middleware.ts` next to the admin routes and it runs for that subtree only. An optional root `middleware.ts` runs on every app request, outermost to innermost. Some requests are answered before it and never reach it, and the RULE is what to remember, not the list: anything the listener shell or the framework's pre-analysis stage answers bypasses root middleware, and everything routed with the app reaches it. That covers WebSocket upgrades bound for a `route.ts` exporting `WS`, the dev SSE stream at `/__webjs/events`, and the framework's own `/__webjs/*` runtime assets and probes; in DEV only it also covers `/public/*` plus the `/sw.js` / `/offline.html` root remaps and `/favicon.ico`, so a stylesheet is never queued behind the dev startup analysis. In production those static files go through root middleware normally. **`webjs.redirects` and `webjs.trailingSlash` are the case intuition gets wrong**: you configure them, but the framework resolves them ahead of middleware, so a 308 from a redirect rule is answered without root middleware running (redirect in the middleware instead when it has to observe those requests). Server actions go the other way: they are routed with the app, so middleware DOES run for an action call, which is what lets you gate actions with auth or rate limiting.
|
|
206
206
|
|
|
207
207
|
### No `<Link>`, no `next/navigation`, no `next/*` libraries
|
|
208
208
|
|
|
@@ -210,10 +210,14 @@ Navigation is automatic. The client router auto-enables when `@webjsdev/core` lo
|
|
|
210
210
|
|
|
211
211
|
### No `<ScrollRestoration>`, and no scroll restore of your own
|
|
212
212
|
|
|
213
|
-
Remix ships a `<ScrollRestoration />` component, Next has a `scrollRestoration` flag and a pile of community `useEffect` + `scrollTo` recipes, and every one of them is a thing to NOT port. WebJs restores scroll on Back/Forward automatically: the router
|
|
213
|
+
Remix ships a `<ScrollRestoration />` component, Next has a `scrollRestoration` flag and a pile of community `useEffect` + `scrollTo` recipes, and every one of them is a thing to NOT port. WebJs restores scroll on Back/Forward automatically, and the BROWSER is what does it: the router reserves the page's recorded height across the swap so the browser's own per-entry replay lands correctly, and writes no scroll itself. There is no component to render and no option to enable. An app-level `popstate` listener that calls `scrollTo`, a remembered offset in `sessionStorage`, or a `scrollIntoView` on a saved element all race the router and win sometimes, which is worse than losing consistently.
|
|
214
|
+
|
|
215
|
+
**Do not set `history.scrollRestoration = 'manual'` either**, which is the one line most of those recipes start with. The browser only records a per-entry scroll offset under the default `auto`, and WebKit composes the iOS edge back-swipe gesture preview from that recording, so `manual` makes every scrolled page preview BLANK for the whole gesture (#1428). The router itself used to set it, inherited from Turbo Drive, and had the same bug. It no longer does.
|
|
214
216
|
|
|
215
217
|
This includes the case that most tempts a hand-rolled fix: Back landing BELOW where the reader left, on a page whose components size themselves after they render. The router already handles it, by suppressing the browser's scroll anchoring across the restore so late growth above the viewport is not added to the offset it just replayed (see `client-router-and-streaming.md`). If a restore still lands wrong, report it rather than patching around it in app code.
|
|
216
218
|
|
|
219
|
+
**One scroll reflex DOES port, and only one.** Next's `<Link scroll={false}>` has a WebJs spelling: `data-preserve-scroll` on the link, or on any element wrapping a group of them, and `navigate(url, { scroll: false })` programmatically. It suppresses the forward-navigation scroll-to-top, which is a write the ROUTER makes, and that is why it exists while none of the restore recipes above do: the restore is the browser's and the router is not a writer on it. Everything else in this section stands unchanged, including the rule not to hand-roll a restore. The attribute keeps the reader's CURRENT offset on a forward nav; it does not bring back the offset they once had on the destination, which is what a hand-rolled `sessionStorage` restore is usually reaching for.
|
|
220
|
+
|
|
217
221
|
### Server-only code: the `.server.ts` boundary, not a `server-only` package
|
|
218
222
|
|
|
219
223
|
Next poisons a client-imported module with the `server-only` package. WebJs uses the file extension: `*.server.ts` is the path-level boundary (the file router refuses to serve the source). A `'use server'` file's exports are RPC-callable; a `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser import throws at load. Reach a no-`'use server'` utility through a `'use server'` action, `route.ts`, or `middleware`, never by direct import into a shipping page or component.
|
|
@@ -286,6 +290,26 @@ class StudentCard extends WebComponent({ student: prop<Student>(Object) }) {
|
|
|
286
290
|
|
|
287
291
|
The `@property()` decorator is banned by the erasable-TS invariant (decorators are non-erasable, they would force a build step). A `static properties = { ... }` block THROWS at runtime (`no-static-properties`). The single replacement for both is the declare-free base-class factory `WebComponent({ ... })`, with the `prop()` helper carrying options.
|
|
288
292
|
|
|
293
|
+
### Passing a method straight to an `@event` binding
|
|
294
|
+
|
|
295
|
+
Lit invokes an `@event` listener with `this` set to the host element, so `@click=${this.handleClick}` is correct there. WebJs stores the handler verbatim and dispatches it through an internal part object (`part.handler?.(ev)` in `core/src/render-client/parts.js`), so `this` is THAT object, not your component. It does not fail loudly. A read such as `this.todos` returns `undefined`, and a write such as `this.count = 1` silently lands on the framework's internal object. The `TypeError` arrives later, when you dereference the `undefined` you read back, so the stack points away from the real cause. Nothing catches it statically either: `webjs check` and `tsc` both pass and the component SSRs correctly.
|
|
296
|
+
|
|
297
|
+
Wrap the call at the binding site, which is the conventional spelling, or declare the handler as an arrow class field, which carries its own `this`.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
// BROKEN: `this` is undefined when the event fires.
|
|
301
|
+
html`<form @submit=${this.handleSubmit}>`
|
|
302
|
+
|
|
303
|
+
// Wrap it at the binding site:
|
|
304
|
+
html`<form @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>`
|
|
305
|
+
|
|
306
|
+
// Or pre-bind by declaring the handler as an arrow field:
|
|
307
|
+
_onSubmit = (e: SubmitEvent) => { /* ... */ };
|
|
308
|
+
html`<form @submit=${this._onSubmit}>`
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
An arrow class field is a class-field initializer, but it is NOT a reactive property, so the reactive-property class-field ban does not apply to it and `reactive-props-no-class-field` does not flag it.
|
|
312
|
+
|
|
289
313
|
### Expecting shadow DOM and reaching for scoped CSS
|
|
290
314
|
|
|
291
315
|
Lit defaults to shadow DOM, so `static styles = css` scopes automatically. WebJs defaults to light DOM. A `static styles` block without `static shadow = true` does nothing useful and any inline `<style>` with bare class names leaks globally. The webjs-shaped fix is Tailwind utilities, which apply directly in light DOM. Reach for `static shadow = true` plus `static styles` only when scoped CSS genuinely belongs in a shadow root, or prefix every selector with the tag name if authoring vanilla light-DOM CSS.
|
|
@@ -85,7 +85,7 @@ TodoList.register('todo-list');
|
|
|
85
85
|
|
|
86
86
|
### Author the optimistic mutation as a degrade-first form
|
|
87
87
|
|
|
88
|
-
Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler.
|
|
88
|
+
Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler. Note the arrow wrapper on the listener: WebJs does not bind an `@event` handler to your component, so passing the method directly would leave `this` pointing at a framework-internal object (see `muscle-memory-gotchas.md`).
|
|
89
89
|
|
|
90
90
|
The SAME imported function is the form binding and the optimistic path's callee, which is what makes a degrade-first form cheap to write: there is no second wiring to keep in step.
|
|
91
91
|
|
|
@@ -94,7 +94,7 @@ import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
|
|
|
94
94
|
|
|
95
95
|
render() {
|
|
96
96
|
return html`
|
|
97
|
-
<form action=${createTodo} @submit=${this.handleSubmit}>
|
|
97
|
+
<form action=${createTodo} @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>
|
|
98
98
|
<input name="title" required>
|
|
99
99
|
<button>Add</button>
|
|
100
100
|
</form>
|
|
@@ -130,19 +130,31 @@ The component reads that seeded prop as its `optimistic()` `source`, so `source:
|
|
|
130
130
|
For a boolean toggle where the value itself is the mutation (like, follow, pin), `optimistic(signal, value, action)` is a thin wrapper over the signal primitive.
|
|
131
131
|
|
|
132
132
|
```ts
|
|
133
|
-
import { signal, optimistic } from '@webjsdev/core';
|
|
133
|
+
import { WebComponent, prop, signal, optimistic, html } from '@webjsdev/core';
|
|
134
134
|
import { likePost } from '#modules/posts/actions/like-post.server.ts';
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
class LikeButton extends WebComponent({ postId: prop(String) }) {
|
|
137
|
+
// INSTANCE scope: one signal per element. A module-scope `signal()` is SHARED
|
|
138
|
+
// across every instance (invariant 5), so a feed of these would all flip
|
|
139
|
+
// together on one click. Scope per-item state to the instance.
|
|
140
|
+
private liked = signal(false);
|
|
141
|
+
|
|
142
|
+
private async toggle() {
|
|
143
|
+
const next = !this.liked.get();
|
|
144
|
+
// Returns the action's ActionResult, so a { success: false } is readable
|
|
145
|
+
// by the caller. The rollback has already happened by then.
|
|
146
|
+
return optimistic(this.liked, next, () => likePost(this.postId));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render() {
|
|
150
|
+
return html`<button @click=${() => this.toggle()}>${this.liked.get() ? 'Liked' : 'Like'}</button>`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
LikeButton.register('like-button');
|
|
144
154
|
```
|
|
145
155
|
|
|
156
|
+
Reserve MODULE scope for state that genuinely is app-wide (a theme, a cart count, a sidebar-open flag), which is the case invariant 5's "module-scope signals share state across components" exists to serve. Per-item state goes on the instance, as above. `liked` is a plain instance field, not a reactive property declared in the factory, so the class-field ban does not reach it and `reactive-props-no-class-field` does not flag it. `gallery/modules/optimistic-ui/components/like-button.ts` is the same shape in shipped code.
|
|
157
|
+
|
|
146
158
|
It rolls back on a thrown error OR an `ActionResult` `{ success: false }` envelope, and never on success. It is client-only (it mutates a signal), so a component importing it is never elided as a display-only component.
|
|
147
159
|
|
|
148
160
|
## When Optimistic UI Is Appropriate
|
|
@@ -18,6 +18,30 @@ Pages and layouts run **only on the server** to produce HTML. They do NOT hydrat
|
|
|
18
18
|
|
|
19
19
|
`route.ts` is the one routing file that is NOT isomorphic: a server-only HTTP handler, never shipped to the client.
|
|
20
20
|
|
|
21
|
+
### How much of the page belongs in the component
|
|
22
|
+
|
|
23
|
+
"Put every interactive behaviour in a component" is not "put the section containing it in a component". Because a page never hydrates, the markup it renders costs the browser nothing, so a page that keeps its static content and delegates only the interactive fragment is both the cheapest and the conventional shape:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// app/products/[id]/page.ts
|
|
27
|
+
export default async function Product({ params }: PageProps<'/products/[id]'>) {
|
|
28
|
+
const product = await getProduct(params.id);
|
|
29
|
+
return html`
|
|
30
|
+
<article>
|
|
31
|
+
<h1>${product.name}</h1>
|
|
32
|
+
<p>${product.description}</p>
|
|
33
|
+
<spec-table .rows=${product.specs}></spec-table>
|
|
34
|
+
|
|
35
|
+
<add-to-cart product-id=${product.id}></add-to-cart>
|
|
36
|
+
|
|
37
|
+
<review-list .reviews=${product.reviews}></review-list>
|
|
38
|
+
</article>
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Only `<add-to-cart>` ships. `<spec-table>` and `<review-list>` are display-only, so the framework elides them and the browser fetches neither. Wrapping the whole article in a `<product-page>` component to "own the page" would ship all three, because a component rendered by a component that ships can no longer be elided. The sizing rule and a before/after are in `components.md` under "Sizing an island".
|
|
44
|
+
|
|
21
45
|
## Pages (`app/**/page.ts`)
|
|
22
46
|
|
|
23
47
|
The default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. It returns a `TemplateResult`; it never calls `render()` itself.
|
|
@@ -187,8 +211,18 @@ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere
|
|
|
187
211
|
|
|
188
212
|
- `error.ts` default-exports `({ error, ...ctx }) => TemplateResult`; catches sibling-page and deeper render errors, innermost wins (prod sends only `error.message`).
|
|
189
213
|
- `loading.ts` wraps the sibling page in `Suspense` with an immediately-flushed fallback.
|
|
190
|
-
- `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal.
|
|
191
|
-
-
|
|
214
|
+
- `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal, and receive the same ctx a page does (`params`, `searchParams`, `url`).
|
|
215
|
+
- **Every one of those boundaries renders INSIDE the layouts at and above its own segment** (#1298), so it carries the keyed `wj:children` pairs and a client-router navigation into a failing page stays a SOFT navigation with the surrounding chrome and its hydrated state intact. A layout deeper than the boundary is not rendered (it never rendered on the way in), and its module is not in the boundary's boot script; the boundary's own module IS. Since a boundary sits inside its own segment's layout, it cannot catch that layout, matching Next's `layout -> error -> page` hierarchy. What happens next differs by path, and the difference is worth knowing:
|
|
216
|
+
|
|
217
|
+
- On the **500 path**, a throwing layout is handled by the next `error.{js,ts}` OUT: the walk tries each boundary in the chain, innermost first, and a layout that throws fails every attempt whose wrapped set contains it, so control ends at `global-error` (or the default 500 page) when they are exhausted.
|
|
218
|
+
- On the **404 / 403 / 401 paths** there is NO outward walk. Each renders the one nearest boundary, so a throwing layout degrades that response to a chrome-less standalone render of the boundary with no boot script, keeping its status. A control-flow throw from a wrapped layout there (an auth-gate layout calling `redirect('/login')`) is discarded rather than honoured, deliberately: the status is already decided and the boundary page is the answer to that request.
|
|
219
|
+
|
|
220
|
+
A genuine layout crash is reported to `onError` (and to the dev overlay on the 404 / 403 / 401 paths, where nothing else claimed the frame) rather than being swallowed. Repeats of the SAME cause within one request collapse to a single report, because one shared layout can fail every boundary attempt (and, when the layout is what threw, arrives again as the error that produced the 500). The key is the STAGE plus the error's name, message and construction site: the stack below that site records how the throw was reached and differs on every re-render, so it cannot be part of the key, and the stage is what keeps `global-error`'s own crash from being swallowed by a boundary that failed through the same helper. Two DIFFERENT failures are both reported, and anything whose key cannot be derived safely (a non-Error throw) is always reported rather than risking a drop. A control-flow sentinel never is, since it is routing rather than a crash.
|
|
221
|
+
|
|
222
|
+
The BOUNDARY FILE's own crash (it throws, or fails to import at all) is reported the same way, and its response body follows the framework's standard rule for a thrown error: shown in dev, withheld in prod, where the page carries only its status. A thrown message is not author-controlled and may name a driver, a path or a connection string, so it does not reach the client; sanitizing the response never means losing the failure.
|
|
223
|
+
|
|
224
|
+
Two further consequences: a layout that fetches runs its fetch again on a boundary response, and a `<webjs-suspense>` inside a wrapped layout shows its fallback, because a boundary response is buffered so its status is final before the first byte. A 404 for a URL that matched NO route has no chain to wrap in and stays a bare document.
|
|
225
|
+
- Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). That verbatim document is exactly why it is the one boundary left UNWRAPPED: a second shell would nest inside the root layout's, wrapping it would re-run the code that just threw, and with no boot script it could not soft-swap anyway. `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
|
|
192
226
|
|
|
193
227
|
Metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `apple-icon.ts`, `opengraph-image.ts`, `twitter-image.ts`) live at app root or static segments and default-export a possibly-async function; `sitemap()` / `sitemapIndex()` from `@webjsdev/server` serialize spec-valid XML.
|
|
194
228
|
|
|
@@ -20,7 +20,7 @@ Pick a runtime from the deploy target, not the code. Default to Node unless you
|
|
|
20
20
|
Three seams pick a runtime-specific implementation, all inside the framework, none in your app:
|
|
21
21
|
|
|
22
22
|
- **The listener.** `startServer` selects the `node:http` request shell on Node and a native `Bun.serve` shell on Bun. Both parse the request, run middleware, dispatch to your routes, and stream the response through the same downstream pipeline, so an SSR page, a server action RPC, and a route handler behave identically.
|
|
23
|
-
- **The type stripper.** WebJs serves `.ts` / `.
|
|
23
|
+
- **The type stripper.** WebJs serves `.ts` / `.mts` as ES modules by erasing the types in place with no bundler. Those two are the whole set: there is no JSX anywhere in the framework, so a `.tsx` file is not served. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`).
|
|
24
24
|
- **A few built-ins.** SQLite, hot reload, and WebSockets each bind to the runtime's native primitive (see the table).
|
|
25
25
|
|
|
26
26
|
## Node vs Bun at a glance
|
|
@@ -35,8 +35,13 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
|
|
|
35
35
|
| Hot reload | `node --watch` | `bun --hot` |
|
|
36
36
|
| WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
|
|
37
37
|
| 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
|
|
38
|
+
| Dev edit to a page / layout | full reload (the `node --watch` restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
|
|
38
39
|
| Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
|
|
39
40
|
|
|
41
|
+
**The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying.
|
|
42
|
+
|
|
43
|
+
Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. Node's `bun --hot` equivalent is `node --watch`, which RESTARTS the process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
|
|
44
|
+
|
|
40
45
|
The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.
|
|
41
46
|
|
|
42
47
|
Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client, only `http` and `https` are accepted as a scheme, and a malformed host is ignored rather than failing the request. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site.
|
|
@@ -36,7 +36,20 @@ When custom CSS IS unavoidable inside a light-DOM component, the tag-prefix inva
|
|
|
36
36
|
|
|
37
37
|
## DRY via a JS helper, not `@apply`
|
|
38
38
|
|
|
39
|
-
When the same Tailwind bundle repeats across 2+ places, extract it into a helper
|
|
39
|
+
When the same Tailwind bundle repeats across 2+ places, extract it into a helper that returns an `html` fragment (SSR-time, no client runtime, output identical to inline classes). Where the helper LIVES follows the narrowest-owner rule, so pick the tier by who consumes it:
|
|
40
|
+
|
|
41
|
+
| Consumers | Home |
|
|
42
|
+
|---|---|
|
|
43
|
+
| routes across the app (a heading, a lede, a back link) | `lib/utils/ui.ts` |
|
|
44
|
+
| one feature (a todo row, a comment card, a board) | `modules/<feature>/utils/ui/<name>.ts` |
|
|
45
|
+
|
|
46
|
+
One file per fragment under `utils/ui/`, because a feature accumulates several and one-per-file keeps them greppable. A fragment promotes from the feature tier to `lib/` only when a second feature genuinely consumes it.
|
|
47
|
+
|
|
48
|
+
The app-wide tier grows the same way, on the same judgment `references/module-structure.md` applies to any module. `lib/utils/ui.ts` is where it starts and where it usually stays: small, independent, one-element helpers belong together in one file, however many of them there are (the blog example keeps nine there quite happily). Split to `lib/ui/<name>.ts`, one file per fragment, when a fragment stops being a one-liner, when one composes others, or when the single file is no longer scannable. The framework's own website crossed that line and its four composed page fragments live in `lib/ui/`.
|
|
49
|
+
|
|
50
|
+
So `modules/<feature>/utils/ui/`, `lib/utils/ui.ts`, and `lib/ui/` are one convention at three sizes rather than three conventions, and the `ui` segment is the part carrying the meaning at every one of them: inside `modules/<feature>/`, `components/` holds custom elements, `utils/ui/` holds functions returning a `TemplateResult`, and the rest of `utils/` holds functions returning data. Drop the segment and a view fragment ends up beside a pure data helper with nothing in the path to tell them apart.
|
|
51
|
+
|
|
52
|
+
The example below is the app-wide tier:
|
|
40
53
|
|
|
41
54
|
```ts
|
|
42
55
|
import { html } from '@webjsdev/core';
|
|
@@ -62,12 +75,44 @@ export default function Post({ params }) {
|
|
|
62
75
|
| Repeats | Action |
|
|
63
76
|
|---|---|
|
|
64
77
|
| Once | Inline the classes. |
|
|
65
|
-
| 2 to 3 times, identical | Extract to `
|
|
78
|
+
| 2 to 3 times, identical, inside ONE feature | Extract to `modules/<feature>/utils/ui/<name>.ts`. |
|
|
79
|
+
| 2 to 3 times, identical, across features or routes | Extract to `lib/utils/ui.ts`. |
|
|
66
80
|
| Varies by 1 to 2 props | Extract with a small parameter (`mb: 'sm' \| 'md'`). |
|
|
67
81
|
| Radically different per call site | Keep inline, do not force-fit. |
|
|
68
82
|
|
|
69
83
|
Avoid `@apply`: it hides which utilities a class uses and creates a second source of truth. A JS helper keeps the bundle visible at the definition site, composes with conditional classes and active states, and runs at SSR time.
|
|
70
84
|
|
|
85
|
+
### Fragment or display-only component?
|
|
86
|
+
|
|
87
|
+
Two questions, in order.
|
|
88
|
+
|
|
89
|
+
**First, is this a UNIT or a repeated CLASS BUNDLE?** The helpers this section began with (a heading, a lede, a back link) are the second kind: one element with a class list you did not want to type twice. That is a fragment by definition and never a component; nobody wants `<page-lede>` as a tag in their DOM, and promoting a class bundle to an element is the same over-reach as absorbing a page section into an island. The question below only arises for a genuine unit of markup (a row, a card, a board) that could reasonably be either.
|
|
90
|
+
|
|
91
|
+
**Second, for a unit: can the markup carry an extra wrapper element at all?** A component is a tag in the DOM, so choosing one adds a node between the parent and the markup. Usually that is fine and the component is the better choice, since it gets a tag name to target and can grow behaviour later. Two cases make it impossible outright:
|
|
92
|
+
|
|
93
|
+
| Case | What happens |
|
|
94
|
+
|---|---|
|
|
95
|
+
| a `<table>` / `<tbody>` child | the parser FOSTER-PARENTS the element out of the table (an HTML spec rule, not a WebJs one), so it lands BEFORE the table and its cells are adopted by a `<tr>` it no longer owns. The component never renders where you put it |
|
|
96
|
+
| output that is not DOM | a `<webjs-stream>` payload, or HTML a `route.ts` returns, is a STRING, and a component has no way to produce one |
|
|
97
|
+
|
|
98
|
+
Three more render fine and are wrong in ways that surface later, so treat them as strong reasons rather than hard blocks. Measured in Chromium, the element survives in all three:
|
|
99
|
+
|
|
100
|
+
| Case | What survives, what breaks |
|
|
101
|
+
|---|---|
|
|
102
|
+
| a `<ul>` / `<ol>` / `<dl>` child | the list renders, but `ul > li`, `:nth-child`, and list markers now see the wrapper instead of the row |
|
|
103
|
+
| a `<select>` child | the control still offers a wrapped `<option>` (it is in `select.options`), but it is no longer `select > option`, so selector-based CSS and DOM code miss it, and the content model is invalid |
|
|
104
|
+
| a `grid` / `flex` child | the wrapper becomes THE ITEM, so the children you meant to lay out sit one level too deep and the track sizing applies to the wrong box |
|
|
105
|
+
|
|
106
|
+
Everywhere else the two are interchangeable, and the remaining difference is cost. Be precise about that too, because the obvious summary ("fragments are free, components ship") is wrong in both directions.
|
|
107
|
+
|
|
108
|
+
**Rendered only by pages, they cost the same: nothing.** A page is inert or import-only, so a fragment it calls runs at SSR and is never fetched. A component that does no client work is elided, so it is never fetched either. Byte arguments do not decide this case; pick whichever reads better.
|
|
109
|
+
|
|
110
|
+
**Rendered by a shipping island, BOTH ship.** A module a shipping component imports is fetched, fragment or not, so the fragment's function is downloaded too (verify it yourself: watch the network panel for the helper's path). What differs is what else comes with it. The component ships a custom element class plus its registration and upgrades once per instance, and, because elision propagates downward, it un-elides every display-only component IT renders. The fragment ships a function and stops there.
|
|
111
|
+
|
|
112
|
+
So near an island the fragment is the smaller and more predictable choice, not a free one, and the gap is a class and its blast radius rather than everything. That is a tiebreaker, not a rule: it is worth acting on where a shipping island is or might become the renderer, and not worth reorganising page-level markup over. When it matters, measure with `webjs elision` rather than reasoning from either rule of thumb.
|
|
113
|
+
|
|
114
|
+
**The summary.** A repeated class bundle is always a fragment. For a real unit, take the fragment where a wrapper element cannot exist (a table child, or string output) and where it would land wrong (a list, select, grid, or flex child); take the component whenever the markup needs behaviour, wants a tag to target, or might grow either; and treat the byte difference as the last consideration rather than the first.
|
|
115
|
+
|
|
71
116
|
### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
|
|
72
117
|
|
|
73
118
|
An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
|
|
@@ -33,7 +33,3 @@ in [`CONVENTIONS.md`](../CONVENTIONS.md) for the full guidance.
|
|
|
33
33
|
there.
|
|
34
34
|
- [ ] **Scaffold scripts / codegen** (if the project has any). Updated
|
|
35
35
|
when the change affects what new instances generate.
|
|
36
|
-
- [ ] **Pre-merge self-review loop.** Ran N rounds; last round clean.
|
|
37
|
-
Skip only for one-line trivial changes. See the **Pre-merge
|
|
38
|
-
self-review loop** section in [`CONVENTIONS.md`](../CONVENTIONS.md)
|
|
39
|
-
for the prompt template and reporting contract.
|
|
@@ -51,6 +51,17 @@ export default function BoundariesExample() {
|
|
|
51
51
|
<code class="font-mono">not-found.ts</code> (404).
|
|
52
52
|
</li>
|
|
53
53
|
</ul>
|
|
54
|
+
<p class="text-muted-foreground text-sm">
|
|
55
|
+
Follow the first three links and notice what does NOT happen: the page
|
|
56
|
+
does not reload. A boundary renders inside the layouts at and above its
|
|
57
|
+
own segment, so the surrounding chrome survives and the navigation stays
|
|
58
|
+
a soft one. Two cases still reload, both for the same reason (there is no
|
|
59
|
+
shared shell to swap into): the fourth link, whose URL matches no route
|
|
60
|
+
at all, so there is no layout chain to render the
|
|
61
|
+
<code class="font-mono">not-found.ts</code> inside; and
|
|
62
|
+
<code class="font-mono">global-error.ts</code>, which owns its whole
|
|
63
|
+
document.
|
|
64
|
+
</p>
|
|
54
65
|
<p class="text-muted-foreground text-sm">
|
|
55
66
|
<code class="font-mono">forbidden()</code> is for an authenticated user who
|
|
56
67
|
lacks permission (403); <code class="font-mono">unauthorized()</code> is for
|
|
@@ -26,13 +26,24 @@ export default function ClientRouterExample() {
|
|
|
26
26
|
<a href="/features/client-router/second" class="${buttonClass()} no-underline">Go to page two</a>
|
|
27
27
|
<a href="/" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Home</a>
|
|
28
28
|
</div>
|
|
29
|
-
<p class="text-muted-foreground text-sm mt-6">
|
|
29
|
+
<p class="text-muted-foreground text-sm mt-6">
|
|
30
|
+
Rendered on the server at
|
|
31
|
+
<code class="font-mono">${new Date().toISOString().slice(11, 19)}</code> UTC.
|
|
32
|
+
This page function runs only on the server, so this stamp changes on every
|
|
33
|
+
render, which is what makes <code class="font-mono">refreshPage()</code>
|
|
34
|
+
below visible.
|
|
35
|
+
</p>
|
|
36
|
+
<p class="text-muted-foreground text-sm mt-6">Or drive it from JS with <code class="font-mono">navigate()</code> / <code class="font-mono">revalidate()</code> / <code class="font-mono">refreshPage()</code>:</p>
|
|
30
37
|
<router-controls></router-controls>
|
|
31
38
|
<p class="text-muted-foreground text-sm mt-6">
|
|
32
39
|
Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
|
|
33
40
|
or per-link with <code class="font-mono">data-no-router</code> (use it for
|
|
34
41
|
auth flows like <code class="font-mono">/logout</code> that must reset
|
|
35
|
-
in-memory state).
|
|
42
|
+
in-memory state). A forward navigation scrolls to top; per link (or per
|
|
43
|
+
wrapping element) <code class="font-mono">data-preserve-scroll</code> keeps
|
|
44
|
+
the reader where they are, for a filter or tab link that changes only part
|
|
45
|
+
of what they are looking at. A hash link still scrolls to its anchor, and
|
|
46
|
+
a frame-targeted link never scrolled anyway.
|
|
36
47
|
</p>
|
|
37
48
|
`;
|
|
38
49
|
}
|
|
@@ -42,7 +42,13 @@ export default function MetadataExample({
|
|
|
42
42
|
Current title source:
|
|
43
43
|
<code class="font-mono text-sm">${topic ? '?topic=' + topic : '(default, no ?topic=)'}</code>
|
|
44
44
|
</p>
|
|
45
|
-
|
|
45
|
+
<!-- data-preserve-scroll keeps the reader's scroll offset instead of
|
|
46
|
+
jumping to the top on click. These links change only the query string
|
|
47
|
+
of the page you are already on, so the control you just used would
|
|
48
|
+
otherwise scroll out from under you. It sits on the <ul> and the router
|
|
49
|
+
resolves it with closest(), so one mark covers every link inside; a
|
|
50
|
+
single link can opt back out with data-preserve-scroll="false". -->
|
|
51
|
+
<ul class="list-disc pl-5 mb-4" data-preserve-scroll>
|
|
46
52
|
<li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=webjs">?topic=webjs</a></li>
|
|
47
53
|
<li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=Routing">?topic=Routing</a></li>
|
|
48
54
|
<li><a class="text-primary underline underline-offset-2" href="/features/metadata">clear the param</a></li>
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
// Programmatic client navigation. `navigate(url)` does the same soft, in-place
|
|
2
2
|
// swap an <a> click does, but from an event handler (after a save, a wizard
|
|
3
3
|
// step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next
|
|
4
|
-
// visit refetches fresh HTML instead of the cached page.
|
|
4
|
+
// visit refetches fresh HTML instead of the cached page.
|
|
5
|
+
// `navigate(url, { scroll: false })` is the programmatic twin of
|
|
6
|
+
// `data-preserve-scroll` on a link: the same soft swap, without the
|
|
7
|
+
// scroll-to-top. Reach for it after an in-page action that changes the URL but
|
|
8
|
+
// should not move the reader.
|
|
9
|
+
// `refreshPage(mode?)` re-renders the page you are ALREADY on and swaps the
|
|
10
|
+
// result in place, recording no history entry and never scrolling, so the reader
|
|
11
|
+
// keeps their place. 'page' (the default) morphs the deepest shared boundary, so
|
|
12
|
+
// hydrated component state outside it survives; 'shell' replaces the whole body,
|
|
13
|
+
// which is what a layout change needs. `disableClientRouter()`
|
|
5
14
|
// / `enableClientRouter()` turn soft navigation off / back on at runtime (for a
|
|
6
15
|
// moment where you want a full page load, e.g. handing off to a third-party
|
|
7
16
|
// flow). disableClientRouter() removes the document-level <a>/<form> click
|
|
@@ -11,7 +20,7 @@
|
|
|
11
20
|
// All are client-only (they run in the browser), so a component is the right
|
|
12
21
|
// home; a page/layout never hydrates. With JS off the plain link still works
|
|
13
22
|
// (progressive enhancement), while the buttons are inert.
|
|
14
|
-
import { WebComponent, html, signal, navigate, revalidate, disableClientRouter, enableClientRouter } from '@webjsdev/core';
|
|
23
|
+
import { WebComponent, html, signal, navigate, revalidate, refreshPage, disableClientRouter, enableClientRouter } from '@webjsdev/core';
|
|
15
24
|
import { buttonClass } from '#components/ui/button.ts';
|
|
16
25
|
|
|
17
26
|
export class RouterControls extends WebComponent {
|
|
@@ -32,13 +41,25 @@ export class RouterControls extends WebComponent {
|
|
|
32
41
|
<button
|
|
33
42
|
@click=${() => navigate('/features/client-router/second')}
|
|
34
43
|
class=${buttonClass({ variant: 'secondary' })}>navigate() to page two</button>
|
|
44
|
+
<button
|
|
45
|
+
@click=${() => navigate('/features/client-router/second', { scroll: false })}
|
|
46
|
+
class=${buttonClass({ variant: 'secondary' })}>navigate(..., { scroll: false })</button>
|
|
35
47
|
<button
|
|
36
48
|
@click=${() => revalidate()}
|
|
37
49
|
class=${buttonClass({ variant: 'link', size: 'none' })}>revalidate() the snapshot cache</button>
|
|
50
|
+
<button
|
|
51
|
+
@click=${() => refreshPage()}
|
|
52
|
+
class=${buttonClass({ variant: 'link', size: 'none' })}>refreshPage() this page</button>
|
|
38
53
|
<button
|
|
39
54
|
@click=${() => this.toggleRouter()}
|
|
40
55
|
class=${buttonClass({ variant: 'link', size: 'none' })}>${soft ? 'disableClientRouter()' : 'enableClientRouter()'} (soft nav: ${soft ? 'on' : 'off'})</button>
|
|
41
56
|
</div>
|
|
57
|
+
<p class="text-sm text-muted-foreground">
|
|
58
|
+
refreshPage() re-renders THIS url on the server and swaps it in.
|
|
59
|
+
The server time above updates, and your scroll position does not
|
|
60
|
+
move. On a page with hydrated components outside the swapped region,
|
|
61
|
+
their state survives too.
|
|
62
|
+
</p>
|
|
42
63
|
<p class="text-sm text-muted-foreground">
|
|
43
64
|
Plain link:
|
|
44
65
|
<a href="/features/client-router/second" class="text-primary underline">/features/client-router/second</a>.
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
* left sidebar (so they can never drift). A browser-safe data module (no server
|
|
4
4
|
* imports, no client globals): the home flattens the groups into its card grid,
|
|
5
5
|
* and <gallery-nav> renders them grouped. gallery:clear removes this module.
|
|
6
|
+
*
|
|
7
|
+
* Blurbs are INTENT-shaped, not noun-shaped: each one opens on the job you would
|
|
8
|
+
* be doing when you want this demo, because the index is the first thing an
|
|
9
|
+
* agent reads and a card that only names the feature cannot be found by someone
|
|
10
|
+
* who does not know the feature exists. The skill's cheat sheet
|
|
11
|
+
* (.agents/skills/webjs/SKILL.md, "Reach For The Right Primitive") carries the
|
|
12
|
+
* same set keyed the same way, and the repo's
|
|
13
|
+
* test/repo-health/skill-gallery-intent-parity.test.mjs fails if the two fall
|
|
14
|
+
* out of step.
|
|
6
15
|
*/
|
|
7
16
|
export interface NavItem { href: string; title: string; blurb: string; }
|
|
8
17
|
export interface NavGroup { label: string; items: NavItem[]; }
|
|
@@ -11,68 +20,68 @@ export const FEATURE_GROUPS: NavGroup[] = [
|
|
|
11
20
|
{
|
|
12
21
|
label: 'Routing',
|
|
13
22
|
items: [
|
|
14
|
-
{ href: '/features/routing', title: 'Routing', blurb: '
|
|
15
|
-
{ href: '/features/boundaries', title: 'Boundaries', blurb: '
|
|
16
|
-
{ href: '/features/metadata', title: 'Metadata', blurb: '
|
|
23
|
+
{ href: '/features/routing', title: 'Routing', blurb: 'Add a URL to the app, static or with a dynamic [id] segment. The file is the route, so there is no table to register it in.' },
|
|
24
|
+
{ href: '/features/boundaries', title: 'Boundaries', blurb: 'Abandon a render because something is missing or not allowed. Throw notFound / forbidden / unauthorized and the nearest boundary file catches it.' },
|
|
25
|
+
{ href: '/features/metadata', title: 'Metadata', blurb: 'Give a page its own title, description, and social preview without writing head markup yourself.' },
|
|
17
26
|
],
|
|
18
27
|
},
|
|
19
28
|
{
|
|
20
29
|
label: 'Components',
|
|
21
30
|
items: [
|
|
22
|
-
{ href: '/features/components', title: 'Components', blurb: '
|
|
23
|
-
{ href: '/features/directives', title: 'Directives', blurb: '
|
|
24
|
-
{ href: '/features/async-render', title: 'Async render', blurb: '
|
|
31
|
+
{ href: '/features/components', title: 'Components', blurb: 'Make one part of the page respond to a click or hold state. A page never hydrates, so interactivity lives in a component.' },
|
|
32
|
+
{ href: '/features/directives', title: 'Directives', blurb: 'Render a keyed list, or swap a single node when state changes, without re-rendering the component around it.' },
|
|
33
|
+
{ href: '/features/async-render', title: 'Async render', blurb: 'Get server data into the first paint. Await it in async render() rather than fetching after mount, which SSR never runs.' },
|
|
25
34
|
],
|
|
26
35
|
},
|
|
27
36
|
{
|
|
28
37
|
label: 'Data & actions',
|
|
29
38
|
items: [
|
|
30
|
-
{ href: '/features/server-actions', title: 'Server actions', blurb: '
|
|
31
|
-
{ href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts
|
|
32
|
-
{ href: '/features/forms', title: 'Forms', blurb: '
|
|
33
|
-
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: '
|
|
39
|
+
{ href: '/features/server-actions', title: 'Server actions', blurb: 'Call server code from the browser by importing the function. No fetch, no endpoint to name, and the types survive the trip.' },
|
|
40
|
+
{ href: '/features/route-handler', title: 'Route handlers', blurb: 'Expose JSON to a caller outside the app. A server-only route.ts, the WebJs equivalent of a Next route handler.' },
|
|
41
|
+
{ href: '/features/forms', title: 'Forms', blurb: 'Write data from a form that still works with JS off. Binding the action to the form is the whole wiring.' },
|
|
42
|
+
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'Make a mutation feel instant. optimistic() applies the change immediately and rolls it back if the server refuses.' },
|
|
34
43
|
],
|
|
35
44
|
},
|
|
36
45
|
{
|
|
37
46
|
label: 'Client & streaming',
|
|
38
47
|
items: [
|
|
39
|
-
{ href: '/features/client-router', title: 'Client router', blurb: 'Automatic
|
|
40
|
-
{ href: '/features/view-transitions', title: 'View transitions', blurb: '
|
|
41
|
-
{ href: '/features/streaming', title: 'Streaming actions', blurb: '
|
|
42
|
-
{ href: '/features/stream', title: 'Stream updates', blurb: '
|
|
43
|
-
{ href: '/features/suspense', title: 'Suspense boundary', blurb: '
|
|
44
|
-
{ href: '/features/frames', title: 'Frames', blurb: '
|
|
48
|
+
{ href: '/features/client-router', title: 'Client router', blurb: 'Navigate without a full page reload. Automatic the moment a page ships a component, with nothing to import or configure.' },
|
|
49
|
+
{ href: '/features/view-transitions', title: 'View transitions', blurb: 'Cross-fade a navigation instead of snapping. One opt-in meta, plus a marker for elements that must survive the swap.' },
|
|
50
|
+
{ href: '/features/streaming', title: 'Streaming actions', blurb: 'Show tokens or progress as the server produces them. An action returning an async generator, consumed with for await.' },
|
|
51
|
+
{ href: '/features/stream', title: 'Stream updates', blurb: 'Change one element after a write, like appending a row or bumping a count, without redrawing the region around it.' },
|
|
52
|
+
{ href: '/features/suspense', title: 'Suspense boundary', blurb: 'Paint the page before a slow region is ready. The fallback flushes on the first byte and the content streams in behind it.' },
|
|
53
|
+
{ href: '/features/frames', title: 'Frames', blurb: 'Refresh one region on its own with no navigation, like a filtered list or a tab panel. Zero component JS, full-nav fallback with JS off.' },
|
|
45
54
|
],
|
|
46
55
|
},
|
|
47
56
|
{
|
|
48
57
|
label: 'Real-time',
|
|
49
58
|
items: [
|
|
50
|
-
{ href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route
|
|
51
|
-
{ href: '/features/broadcast', title: 'Broadcast', blurb: '
|
|
59
|
+
{ href: '/features/websockets', title: 'WebSockets', blurb: 'Hold a live two-way connection instead of polling. A WS(ws, req) route export on the server, connectWS() on the client.' },
|
|
60
|
+
{ href: '/features/broadcast', title: 'Broadcast', blurb: 'Push one update to every client connected on a WebSocket path, so a change made in one of them shows up in the rest. Optionally excluding the sender.' },
|
|
52
61
|
],
|
|
53
62
|
},
|
|
54
63
|
{
|
|
55
64
|
label: 'Auth & sessions',
|
|
56
65
|
items: [
|
|
57
|
-
{ href: '/features/auth', title: 'Auth', blurb: '
|
|
58
|
-
{ href: '/features/sessions', title: 'Sessions', blurb: 'A signed
|
|
66
|
+
{ href: '/features/auth', title: 'Auth', blurb: 'Add login and a route only signed-in visitors can open, without rolling password hashing and session cookies yourself.' },
|
|
67
|
+
{ href: '/features/sessions', title: 'Sessions', blurb: 'Remember something per visitor across requests. A signed cookie applied by middleware, read and written with getSession().' },
|
|
59
68
|
],
|
|
60
69
|
},
|
|
61
70
|
{
|
|
62
71
|
label: 'Built-ins',
|
|
63
72
|
items: [
|
|
64
|
-
{ href: '/features/caching', title: 'Caching', blurb: '
|
|
65
|
-
{ href: '/features/env', title: 'Env vars', blurb: '
|
|
66
|
-
{ href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware
|
|
67
|
-
{ href: '/features/file-storage', title: 'File storage', blurb: '
|
|
68
|
-
{ href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in
|
|
73
|
+
{ href: '/features/caching', title: 'Caching', blurb: 'Stop re-rendering a page that is identical for every visitor. export const revalidate, with the rule for when that is safe.' },
|
|
74
|
+
{ href: '/features/env', title: 'Env vars', blurb: 'Read config and secrets at runtime while keeping the secrets server-side. WEBJS_PUBLIC_ is the only prefix the browser sees.' },
|
|
75
|
+
{ href: '/features/rate-limit', title: 'Rate limiting', blurb: 'Stop one caller hammering an endpoint. The rateLimit() middleware returns a 429 with Retry-After until the interval resets.' },
|
|
76
|
+
{ href: '/features/file-storage', title: 'File storage', blurb: 'Accept an upload and serve it back, streamed both ways, with nothing buffered in memory and nothing written into public/.' },
|
|
77
|
+
{ href: '/features/service-worker', title: 'Service worker', blurb: 'Keep the app usable offline. The opt-in service worker, registered from a browser-only lifecycle hook (never a page or layout).' },
|
|
69
78
|
],
|
|
70
79
|
},
|
|
71
80
|
];
|
|
72
81
|
|
|
73
82
|
/** The whole example apps (composed features), shown after the single-feature demos. */
|
|
74
83
|
export const EXAMPLES: NavItem[] = [
|
|
75
|
-
{ href: '/examples/todo', title: 'Optimistic todo', blurb: '
|
|
84
|
+
{ href: '/examples/todo', title: 'Optimistic todo', blurb: 'See the pieces composed in one real feature: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
|
|
76
85
|
];
|
|
77
86
|
|
|
78
87
|
/**
|