@webjsdev/cli 0.10.56 → 0.10.58
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/README.md +6 -1
- package/bin/webjs.js +219 -9
- package/lib/app-tasks.js +70 -10
- package/lib/check-target.js +1 -1
- package/lib/ci-config.js +250 -0
- package/lib/ci-runner.js +499 -0
- package/lib/create.js +59 -2
- package/lib/doctor/codes.js +1 -0
- package/lib/doctor/probes/framework-resolves.js +182 -6
- package/lib/doctor/runner.js +2 -1
- package/lib/doctor.js +1 -1
- package/lib/run-tasks.js +23 -3
- package/package.json +3 -3
- package/templates/.agents/rules/workflow.md +19 -12
- package/templates/.agents/skills/webjs/SKILL.md +6 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +45 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +32 -3
- package/templates/.agents/skills/webjs/references/components.md +9 -1
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +26 -2
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +23 -11
- package/templates/.agents/skills/webjs/references/runtime.md +1 -1
- package/templates/.agents/skills/webjs/references/styling.md +48 -3
- package/templates/.agents/skills/webjs/references/testing.md +11 -0
- package/templates/.agents/skills/webjs/references/ui-kit.md +25 -0
- package/templates/.github/pull_request_template.md +3 -8
- package/templates/.github/workflows/ci.yml +38 -88
- package/templates/.hooks/pre-commit +5 -4
- package/templates/gallery/app/features/client-router/page.ts +5 -1
- package/templates/gallery/app/features/metadata/page.ts +7 -1
- package/templates/gallery/modules/client-router/components/router-controls.ts +7 -0
- 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
- package/templates/partials/agents-playbook-api.md +14 -8
- package/templates/partials/agents-playbook-fullstack.md +16 -10
|
@@ -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.
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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,15 +75,47 @@ 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
|
-
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.
|
|
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. Under the opt-in `webjsui lint` (configured by a `lint` block in `components.json`, see `references/ui-kit.md`), `w-9 h-9` is layout and `rounded-full` is shape in the shadcn category taxonomy, so an app running it sets `no-restyle` to `allow: ["layout", "rounded"]` for this idiom to pass: the plain radius group is granted by name without opening the whole `shape` category, and `border-2` beside a helper still fires.
|
|
74
119
|
|
|
75
120
|
```ts
|
|
76
121
|
// components/ui/button.ts (npx webjsdev ui add button, themed to your app)
|
|
@@ -189,6 +189,17 @@ WEBJS_ELIDE=0 npm run test:e2e
|
|
|
189
189
|
|
|
190
190
|
A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
|
|
191
191
|
|
|
192
|
+
## One command for every layer (`webjs ci`)
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
npm run ci # the webjs.ci list: check, doctor, typecheck, audit, then every test layer
|
|
196
|
+
npm run ci -- --only Tests # one step or group by title
|
|
197
|
+
npm run ci -- --fail-fast # stop at the first failure
|
|
198
|
+
npm run ci -- --json # one JSON document for an agent loop
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The scaffold declares its gate once, in `package.json` under `webjs.ci`, and `npm run ci` runs it with a timed result line per step (the Rails `bin/ci` model). The generated GitHub workflow runs the same list, so a green local run predicts CI. Run it before every push; the pre-commit hook deliberately runs none of it so a commit stays fast. Browser and e2e steps need a Chromium on the machine (`npx playwright install chromium`, plus `puppeteer-core` for the e2e layer), which the workflow installs for itself. The list, the flags, the `--signoff` merge gate, and the JSON shape are in `references/built-ins.md` under "Local CI".
|
|
202
|
+
|
|
192
203
|
## Type-checking your tests (`webjs typecheck`)
|
|
193
204
|
|
|
194
205
|
Your tests are inside the tsconfig `include`, so `npm run typecheck` reads them (#1299). Treat a type error in a test as a failed gate, not a review catch: the checker sees a wrong argument shape or an unannotated parameter in a test the same way it sees one in `app/`.
|
|
@@ -52,6 +52,31 @@ So the loop is: `add` the component, then query `ui <name>` (MCP) or
|
|
|
52
52
|
that ships inside the installed `@webjsdev/ui`, with no network. This pins you
|
|
53
53
|
to the installed version; run `npx webjsdev ui diff` to see where your local copies
|
|
54
54
|
drift from the upstream (that command alone compares against the live registry).
|
|
55
|
+
- `npx webjsdev ui lint` is an OPT-IN design-system linter over the app's own
|
|
56
|
+
source. It reads the Tailwind classes in `html` templates, `cn()` calls and
|
|
57
|
+
`class=${...}` holes and reports, at the line, a raw palette colour where the
|
|
58
|
+
theme declares a role token (`no-raw-colors`, with a message naming only the
|
|
59
|
+
`--color-*` tokens the configured `tailwind.css` actually declares), an
|
|
60
|
+
arbitrary value such as `p-[13px]` (`no-arbitrary-values`; an arbitrary
|
|
61
|
+
VARIANT like `[&_svg]:size-4` never fires), and a class composed over a kit
|
|
62
|
+
helper (`no-restyle`, naming the helper's real variants and sizes read from
|
|
63
|
+
the app's copied `components/ui/*.ts`). It is off until `components.json`
|
|
64
|
+
carries a `lint` block, and with no block it reports nothing and exits 0.
|
|
65
|
+
`components/ui/**` is skipped by default (a copied primitive owns structural
|
|
66
|
+
values no variant expresses), and `allow` uses shadcn's category taxonomy
|
|
67
|
+
(`layout`, `color`, `typography`, `spacing`, `shape`, `effects`, `motion`) or
|
|
68
|
+
a class-group id such as `rounded`. `--json` emits `{ violations, summary }`
|
|
69
|
+
for an agent loop; `--max-warnings <n>` pins a count.
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
"lint": {
|
|
73
|
+
"rules": {
|
|
74
|
+
"no-raw-colors": "warn",
|
|
75
|
+
"no-arbitrary-values": { "severity": "warn", "allow": ["layout"] },
|
|
76
|
+
"no-restyle": { "severity": "error", "allow": ["layout", "rounded"] }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
55
80
|
|
|
56
81
|
## Inventory (run `npx webjsdev ui list` or the MCP `ui` tool for the authoritative, current set)
|
|
57
82
|
|
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
## Test plan
|
|
6
6
|
|
|
7
|
-
- [ ]
|
|
8
|
-
- [ ]
|
|
9
|
-
- [ ]
|
|
10
|
-
- [ ] `webjs doctor` passes (project health; it fails on whatever `webjs.doctor.gate` marks `error`, plus the hard `NODE_VERSION` / `TSCONFIG_ERASABLE` checks)
|
|
7
|
+
- [ ] `webjs ci` passes locally (the `webjs.ci` step list in package.json: `webjs check`, `webjs doctor`, `webjs typecheck`, the dependency audit, and the server / browser / e2e test layers; CI runs the same list)
|
|
8
|
+
- [ ] Unit tests added/updated
|
|
9
|
+
- [ ] E2E tests added/updated for user-facing changes (`WEBJS_E2E=1 webjs test`)
|
|
11
10
|
|
|
12
11
|
## Definition of done
|
|
13
12
|
|
|
@@ -33,7 +32,3 @@ in [`CONVENTIONS.md`](../CONVENTIONS.md) for the full guidance.
|
|
|
33
32
|
there.
|
|
34
33
|
- [ ] **Scaffold scripts / codegen** (if the project has any). Updated
|
|
35
34
|
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.
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
name: CI
|
|
2
2
|
|
|
3
|
-
# The
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
3
|
+
# The cloud half of CI for {{APP_NAME}}. The step list itself lives in
|
|
4
|
+
# package.json under "webjs": { "ci": { "steps": [...] } }, and `npm run ci`
|
|
5
|
+
# runs it: on a developer machine before a push, and here on every PR into
|
|
6
|
+
# main and every push to main, so the two can never drift. This job only
|
|
7
|
+
# prepares the runner (Node, dependencies, a browser, the database) and then
|
|
8
|
+
# runs that one command.
|
|
8
9
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# One job on purpose. Each step is still a collapsible log group with its own
|
|
11
|
+
# result line, a failing step is annotated, and the run's step table lands in
|
|
12
|
+
# the job summary, so a failure names the layer that broke. Mark this job as a
|
|
13
|
+
# required status check in the branch-protection rule for main. A team that
|
|
14
|
+
# wants one required check PER LAYER can add a matrix job that runs
|
|
15
|
+
# `npx webjs ci --only "<group title>"` per entry.
|
|
16
|
+
#
|
|
17
|
+
# The local pre-commit hook deliberately runs none of this, so a commit stays
|
|
18
|
+
# fast; `npm run ci` before pushing is the local gate. To hold a merge until a
|
|
19
|
+
# LOCAL run is green, `npm run ci -- --signoff` posts a green commit status
|
|
20
|
+
# through basecamp/gh-signoff, which branch protection can require (`gh signoff
|
|
21
|
+
# install`), the Rails posture. Free on public repos (ubuntu-latest has
|
|
22
|
+
# unlimited Actions minutes).
|
|
13
23
|
|
|
14
24
|
on:
|
|
15
25
|
pull_request:
|
|
@@ -17,40 +27,21 @@ on:
|
|
|
17
27
|
push:
|
|
18
28
|
branches: [main]
|
|
19
29
|
|
|
30
|
+
permissions:
|
|
31
|
+
contents: read
|
|
32
|
+
|
|
20
33
|
# A newer push to the same branch cancels the older in-flight run.
|
|
21
34
|
concurrency:
|
|
22
35
|
group: ci-${{ github.ref }}
|
|
23
36
|
cancel-in-progress: true
|
|
24
37
|
|
|
25
38
|
jobs:
|
|
26
|
-
|
|
27
|
-
name:
|
|
28
|
-
runs-on: ubuntu-latest
|
|
29
|
-
steps:
|
|
30
|
-
- uses: actions/checkout@v6
|
|
31
|
-
- uses: actions/setup-node@v6
|
|
32
|
-
with:
|
|
33
|
-
node-version: '24'
|
|
34
|
-
cache: npm
|
|
35
|
-
- run: npm ci
|
|
36
|
-
- run: npm run check
|
|
37
|
-
# Project health, on top of the correctness checks. WHICH findings are
|
|
38
|
-
# fatal is your call, declared in package.json under
|
|
39
|
-
# "webjs": { "doctor": { "gate": { "<CODE>": "off" | "warn" | "error" } } },
|
|
40
|
-
# so this step and a local `npm run doctor` always agree. The scaffold
|
|
41
|
-
# starts with UNMARKED_ASSET_LINKS at error (an un-versioned /public url
|
|
42
|
-
# is a real deploy-staleness bug). Two checks fail with no gate entry at
|
|
43
|
-
# all, NODE_VERSION and TSCONFIG_ERASABLE, because either would 500 the
|
|
44
|
-
# app at runtime; everything else stays a warn and cannot fail this job.
|
|
45
|
-
# Widen or narrow the gate in package.json, not
|
|
46
|
-
# here. Deliberately not --strict: the git-hook, env-drift, vendor-pin,
|
|
47
|
-
# and framework-resolve checks are environment-shaped and would fail a
|
|
48
|
-
# perfectly healthy runner.
|
|
49
|
-
- run: npm run doctor
|
|
50
|
-
|
|
51
|
-
unit:
|
|
52
|
-
name: Unit + integration (node --test)
|
|
39
|
+
ci:
|
|
40
|
+
name: CI (npm run ci)
|
|
53
41
|
runs-on: ubuntu-latest
|
|
42
|
+
timeout-minutes: 20
|
|
43
|
+
env:
|
|
44
|
+
DATABASE_URL: file:./ci.db
|
|
54
45
|
steps:
|
|
55
46
|
- uses: actions/checkout@v6
|
|
56
47
|
- uses: actions/setup-node@v6
|
|
@@ -58,58 +49,17 @@ jobs:
|
|
|
58
49
|
node-version: '24'
|
|
59
50
|
cache: npm
|
|
60
51
|
- run: npm ci
|
|
61
|
-
- name:
|
|
62
|
-
run: npm run db:generate && npm run db:migrate
|
|
63
|
-
env:
|
|
64
|
-
DATABASE_URL: file:./ci.db
|
|
65
|
-
# --server keeps this job to node:test (the browser layer is its own
|
|
66
|
-
# job below). Without WEBJS_E2E the e2e folders are skipped too.
|
|
67
|
-
- run: npm run test:server
|
|
68
|
-
env:
|
|
69
|
-
DATABASE_URL: file:./ci.db
|
|
70
|
-
|
|
71
|
-
browser:
|
|
72
|
-
name: Browser (web-test-runner / Playwright)
|
|
73
|
-
runs-on: ubuntu-latest
|
|
74
|
-
steps:
|
|
75
|
-
- uses: actions/checkout@v6
|
|
76
|
-
- uses: actions/setup-node@v6
|
|
77
|
-
with:
|
|
78
|
-
node-version: '24'
|
|
79
|
-
cache: npm
|
|
80
|
-
- run: npm ci
|
|
81
|
-
- name: Install Playwright Chromium
|
|
52
|
+
- name: Install Playwright Chromium (browser + e2e layers)
|
|
82
53
|
run: npx playwright install --with-deps chromium
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
- uses: actions/checkout@v6
|
|
90
|
-
- uses: actions/setup-node@v6
|
|
91
|
-
with:
|
|
92
|
-
node-version: '24'
|
|
93
|
-
cache: npm
|
|
94
|
-
- run: npm ci
|
|
95
|
-
- name: Set up the database (generate + apply migrations)
|
|
96
|
-
run: npm run db:generate && npm run db:migrate
|
|
97
|
-
env:
|
|
98
|
-
DATABASE_URL: file:./ci.db
|
|
99
|
-
# The scaffold's e2e test (test/hello/e2e/) drives a real browser
|
|
100
|
-
# via puppeteer-core, which is not a default dependency (the test
|
|
101
|
-
# skips when it is absent). Install it and Chromium so the e2e
|
|
102
|
-
# layer actually runs in CI rather than skipping silently.
|
|
103
|
-
- name: Install puppeteer-core + Chromium
|
|
104
|
-
run: |
|
|
105
|
-
npm install --no-save puppeteer-core
|
|
106
|
-
npx playwright install --with-deps chromium
|
|
54
|
+
# The scaffold's e2e test (test/hello/e2e/) drives a real browser via
|
|
55
|
+
# puppeteer-core, which is not a default dependency (the test skips when
|
|
56
|
+
# it is absent). Install it so the e2e layer runs here rather than
|
|
57
|
+
# skipping silently.
|
|
58
|
+
- name: Install puppeteer-core
|
|
59
|
+
run: npm install --no-save puppeteer-core
|
|
107
60
|
- name: Resolve the Chromium binary path
|
|
108
61
|
run: echo "CHROMIUM_PATH=$(node -e "console.log(require('playwright-core').chromium.executablePath())")" >> "$GITHUB_ENV"
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
WEBJS_E2E: '1'
|
|
114
|
-
DATABASE_URL: file:./ci.db
|
|
115
|
-
run: npm run test:server
|
|
62
|
+
- name: Set up the database (generate + apply migrations)
|
|
63
|
+
run: npm run db:generate && npm run db:migrate
|
|
64
|
+
# Everything above prepares the runner. This is the whole gate.
|
|
65
|
+
- run: npm run ci
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
#
|
|
8
8
|
# To bypass in emergencies: git commit --no-verify
|
|
9
9
|
#
|
|
10
|
-
# Tests and convention checks run
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# on every push and pull
|
|
10
|
+
# Tests and convention checks do not run here, so a commit stays fast. The
|
|
11
|
+
# step list lives in package.json under "webjs": { "ci" }. Run it locally
|
|
12
|
+
# with `npm run ci` before pushing (the local gate), and the CI workflow
|
|
13
|
+
# (.github/workflows/ci.yml) runs the same list on every push and pull
|
|
14
|
+
# request, where a local --no-verify cannot skip it.
|
|
14
15
|
#
|
|
15
16
|
# Running more than one AI agent on this repo at once? Give each task its own
|
|
16
17
|
# git worktree, not a shared checkout. Two agents in one working directory
|
|
@@ -39,7 +39,11 @@ export default function ClientRouterExample() {
|
|
|
39
39
|
Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
|
|
40
40
|
or per-link with <code class="font-mono">data-no-router</code> (use it for
|
|
41
41
|
auth flows like <code class="font-mono">/logout</code> that must reset
|
|
42
|
-
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.
|
|
43
47
|
</p>
|
|
44
48
|
`;
|
|
45
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>
|
|
@@ -2,6 +2,10 @@
|
|
|
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
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.
|
|
5
9
|
// `refreshPage(mode?)` re-renders the page you are ALREADY on and swaps the
|
|
6
10
|
// result in place, recording no history entry and never scrolling, so the reader
|
|
7
11
|
// keeps their place. 'page' (the default) morphs the deepest shared boundary, so
|
|
@@ -37,6 +41,9 @@ export class RouterControls extends WebComponent {
|
|
|
37
41
|
<button
|
|
38
42
|
@click=${() => navigate('/features/client-router/second')}
|
|
39
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>
|
|
40
47
|
<button
|
|
41
48
|
@click=${() => revalidate()}
|
|
42
49
|
class=${buttonClass({ variant: 'link', size: 'none' })}>revalidate() the snapshot cache</button>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Co-located browser test for the stream demo, in real Chromium with real SSR
|
|
2
|
+
// and hydration. The runner UI is `tdd` (suite/test) and there is no assertion
|
|
3
|
+
// library, so a tiny inline assert does the job.
|
|
4
|
+
//
|
|
5
|
+
// This pins the one thing the row fragment has to get right: the SEEDED rows
|
|
6
|
+
// (rendered from the html`` shape) and the STREAMED rows (rendered from the
|
|
7
|
+
// string shape) come off one class list, so a mutation cannot leave the list
|
|
8
|
+
// styled two ways. Both shapes are exercised through the real component.
|
|
9
|
+
import { html } from '@webjsdev/core';
|
|
10
|
+
import { ssrFixture } from '@webjsdev/core/testing';
|
|
11
|
+
import '../stream-demo.ts';
|
|
12
|
+
|
|
13
|
+
const assert = (cond, msg) => { if (!cond) throw new Error(msg || 'assertion failed'); };
|
|
14
|
+
const rows = (el) => [...el.querySelectorAll('#stream-list > li')];
|
|
15
|
+
const button = (el, label) => [...el.querySelectorAll('button')].find((b) => b.textContent.trim() === label);
|
|
16
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
17
|
+
|
|
18
|
+
suite('<stream-demo>', () => {
|
|
19
|
+
test('SSRs the two seeded rows as direct list children', async () => {
|
|
20
|
+
const el = await ssrFixture(html`<stream-demo></stream-demo>`);
|
|
21
|
+
const ids = rows(el).map((li) => li.id);
|
|
22
|
+
assert(ids.join(',') === 'row-1,row-2', `seeded ids, got ${ids}`);
|
|
23
|
+
// A direct child, no wrapper element between the list and the row. That is
|
|
24
|
+
// the structural reason the row is a fragment and not a display-only element.
|
|
25
|
+
assert(rows(el).every((li) => li.parentElement.id === 'stream-list'), 'rows are direct children of the list');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a streamed row carries the same classes as a seeded row', async () => {
|
|
29
|
+
const el = await ssrFixture(html`<stream-demo></stream-demo>`);
|
|
30
|
+
const seeded = rows(el)[0].className;
|
|
31
|
+
button(el, 'Append').click();
|
|
32
|
+
await tick();
|
|
33
|
+
const all = rows(el);
|
|
34
|
+
assert(all.length === 3, `three rows after append, got ${all.length}`);
|
|
35
|
+
const streamed = all[2];
|
|
36
|
+
assert(streamed.id === 'row-3', `appended row is row-3, got ${streamed.id}`);
|
|
37
|
+
assert(streamed.className === seeded, `streamed row classes match seeded:\n ${streamed.className}\n ${seeded}`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('the string shape escapes its holes', async () => {
|
|
41
|
+
// The html`` shape escapes its own holes; this plain-string one has to do
|
|
42
|
+
// it by hand, and it is the shape a reader copies. A raw value here would
|
|
43
|
+
// become markup as soon as renderStream() inserted it.
|
|
44
|
+
const { streamRowHTML } = await import('../../utils/ui/row.ts');
|
|
45
|
+
const out = streamRowHTML('x" onload="boom', '<img src=x onerror=boom>');
|
|
46
|
+
assert(!out.includes('<img'), `text hole is escaped, got ${out}`);
|
|
47
|
+
assert(!out.includes('" onload'), `attribute hole is escaped, got ${out}`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('replace keeps the id and reset restores the seed list', async () => {
|
|
51
|
+
const el = await ssrFixture(html`<stream-demo></stream-demo>`);
|
|
52
|
+
button(el, 'Replace Row 1').click();
|
|
53
|
+
await tick();
|
|
54
|
+
assert(rows(el)[0].id === 'row-1', 'replace keeps row-1');
|
|
55
|
+
assert(rows(el)[0].textContent.includes('replaced'), 'replace swaps the content');
|
|
56
|
+
button(el, 'Prepend').click();
|
|
57
|
+
await tick();
|
|
58
|
+
button(el, 'Reset').click();
|
|
59
|
+
await tick();
|
|
60
|
+
const ids = rows(el).map((li) => li.id);
|
|
61
|
+
assert(ids.join(',') === 'row-1,row-2', `reset restores the seed list, got ${ids}`);
|
|
62
|
+
assert(rows(el)[0].textContent.trim() === 'Row 1', 'reset restores the seed content');
|
|
63
|
+
});
|
|
64
|
+
});
|