You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(core): adopt standard customElements.define — drop static tag + .register()
The web-standard registration convention (same shape Lit uses for its
non-decorator form):
class Counter extends WebComponent { ... }
customElements.define('my-counter', Counter);
Replaces the framework-specific `static tag = '…' + Counter.register()`
pattern. Net change is cosmetic — but it unlocks tsserver tooling
(ts-lit-plugin resolves tags natively, without JSDoc pragmas).
core
----
• registry.js: thin wrapper around customElements.define.
- Browser: monkey-patch customElements.define to mirror into webjs's
internal map + preserve the native upgrade behaviour.
- Server: install a minimal customElements shim on globalThis so
components' `customElements.define(...)` calls at module bottom
don't throw (Node has no platform registry).
- Reverse lookup via tagOf(Class) for framework warnings/logs.
• component.js: drop `static tag` field and `static register()` method.
Framework warning messages now resolve tag via tagOf(Ctor).
• component.d.ts: matching overlay updates.
server-side scanner
-------------------
• component-scanner.js: extractor now looks for
`customElements.define('tag', ClassName)` calls instead of
`static tag = '…'`. Balanced-brace parsing gone — the define call
is a single line, much simpler.
• findOrphanComponents(): new scan that flags classes extending
WebComponent without a matching customElements.define in the same
file.
• dev.js: wires findOrphanComponents into boot + rebuild — dev server
logs a warning like
[webjs] Foo extends WebComponent but has no customElements.define(...)
call in foo.ts. Add `customElements.define('foo', Foo);` or <foo>
tags won't upgrade.
Browser-side silent failure turns into actionable server feedback.
• check.js: rule renamed components-have-register → components-have-define.
tag-name-has-hyphen now detects tags from customElements.define calls.
webjs-plugin
------------
• extractComponents() walks CallExpressions, finds
customElements.define('tag', ClassId), resolves ClassId to a local
ClassDeclaration, returns the definition span.
• Handles both `customElements.define` and `window.customElements.define`.
migration (breaking — pre-launch)
---------------------------------
• All 11 blog components + test fixtures.
• Scaffold theme-toggle template in create.js.
• 15 docs pages + website/docs components.
• AGENTS.md, README.md, examples/blog/AGENTS.md,
scaffold CONVENTIONS.md / cursorrules / copilot-instructions.
tests
-----
• test/check.test.js: rule name + fixtures updated.
• test/component-scanner.test.js: extractor tests rewritten around
customElements.define; 2 new orphan-detection tests.
• test/webjs-plugin.test.js: plugin tests use customElements.define
fixtures.
• test/lazy-loading.test.js, test/render-server.test.js,
test/light-dom-ssr.test.js, test/types/component-types.test-d.ts:
fixtures updated.
Tests: 266 → 269 unit. Browser: 21. All green.
|`html`| Tagged template literal producing a `TemplateResult`. Use in pages, layouts, and component `render()`. |
309
309
|`css`| Tagged template literal producing a `CSSResult`. Assign to `static styles` on components. |
310
310
|`WebComponent`| Base class for interactive components. |
311
-
|`register(tag,C)`| Register a tag → class. Called automatically by `Class.register()`. |
311
+
|`register(tag,C)`| Register a tag → class. Called automatically by `customElements.define('tag', Class)`. |
312
312
|`render(v, el)`| Client-side: render a value into a DOM element. |
313
313
|`renderToString`| Server-side: **async** — render a value to an HTML string with DSD injection. Awaits Promise-valued holes and async component `render()` methods. |
314
314
|`notFound()`| Throw inside a page/layout/server action to return a 404 rendered via `not-found.js`. |
@@ -412,7 +412,6 @@ Event/property/boolean-prefixed attributes **must be unquoted**.
412
412
413
413
```js
414
414
class MyThing extends WebComponent {
415
-
static tag = 'my-thing'; // required
416
415
static shadow = false; // default: light DOM. Set true for scoped shadow DOM
@@ -569,7 +567,6 @@ Pick one of these two patterns and stick to it per component:
569
567
```ts
570
568
// Pattern A — BEM-ish class names prefixed with tag
571
569
class MyCard extends WebComponent {
572
-
static tag = 'my-card';
573
570
render() {
574
571
return html`
575
572
<style>
@@ -585,7 +582,6 @@ class MyCard extends WebComponent {
585
582
586
583
// Pattern B — descendant selector rooted at the tag
587
584
class MyCard extends WebComponent {
588
-
static tag = 'my-card';
589
585
render() {
590
586
return html`
591
587
<style>
@@ -802,7 +798,7 @@ When you mark an action as `expose('METHOD /path', fn)`, you are declaring it pa
802
798
803
799
### Components (`components/*.js`)
804
800
805
-
- Each file should define **one** custom element and call `Class.register()` at module top level.
801
+
- Each file should define **one** custom element and call `customElements.define('tag', Class)` at module top level.
806
802
Passing `import.meta.url` lets the SSR shell emit a `<link rel="modulepreload">` so the browser can fetch the module without waiting for its parent to parse. Zero build step; big first-paint win.
807
803
- Imported by pages (for SSR) and/or other components (for composition).
808
804
- **Styling convention: shadow-DOM CSS via `static styles = css\`…\``, not inline `style="…"` attributes.** Any repeated visual chunk in pages (layout chrome, cards, muted labels, etc.) should become a component whose styles live in its shadow root. The example app's `<blog-shell>` and `<muted-text>` demonstrate this — pages emit semantic HTML with zero inline styles.
@@ -956,7 +952,7 @@ the class-prefix rule documented in the Shadow-vs-Light DOM section.
956
952
957
953
1. **Never import `@prisma/client`, `node:*`, or any server-only dependency from a file under `components/` or from a page's top-level module graph that isn't a server action.** The browser will try to load it and fail. Use a server action instead.
958
954
2. **Every `*.server.js` export must be an `async` JSON-safe function.** Arguments/results are serialised over the wire.
959
-
3. **Custom element tag names must contain a hyphen** (HTML spec). Set `static tag`, call `.register()`.
955
+
3. **Custom element tag names must contain a hyphen** (HTML spec). Set `static tag`, call `customElements.define('tag', Class)`.
960
956
4. **Event (`@`), property (`.`), and boolean (`?`) holes in `html` must be unquoted** — e.g. `@click=${fn}`, never `@click="${fn}"`.
961
957
5. **Do not mutate `this.state` directly** — use `setState`. State reads are fine.
962
958
6. **Page and layout default exports must be functions.** They return a value (usually a `TemplateResult`); they do not call `render()` themselves.
@@ -1044,13 +1040,9 @@ if (!r.success) this.setState({ error: r.error });
1044
1040
// components/hello-world.js
1045
1041
import { WebComponent, html } from'webjs';
1046
1042
exportclassHelloWorldextendsWebComponent {
1047
-
static tag ='hello-world';
1048
1043
render() { returnhtml`<p>Hello!</p>`; }
1049
1044
}
1050
-
HelloWorld.register();
1051
-
```
1052
-
1053
-
Then use it as `<hello-world></hello-world>` in any page or component.
1045
+
customElements.define('hello-wcustomElements.define('my-customElements.define('my-card', HelloWorld);Then use it as `<hello-world></hello-world>`in any page or component.
<p>That is a complete, working component. Import it from a page or layout and use it like any HTML element:</p>
42
41
@@ -47,20 +46,19 @@ export default function Home() {
47
46
}</pre>
48
47
49
48
<h2>Tag Names</h2>
50
-
<p>The HTML spec requires that custom element names contain a <strong>hyphen</strong>. This is how the browser distinguishes <code><my-counter></code> from built-in elements like <code><div></code>. Set the tag via the <code>static tag</code>field:</p>
49
+
<p>The HTML spec requires that custom element names contain a <strong>hyphen</strong>. This is how the browser distinguishes <code><my-counter></code> from built-in elements like <code><div></code>. Register the component with the standard <code>customElements.define()</code>API at the bottom of the file:</p>
51
50
52
51
<pre>class UserCard extends WebComponent {
53
-
static tag = 'user-card'; // must contain a hyphen
<p>If you forget the hyphen, the browser will throw when the element is registered. If you forget <code>static tag</code>entirely, <code>register()</code> throws with a clear error message.</p>
56
+
<p>If you forget the hyphen, the browser throws at <code>customElements.define</code>time with a clear error message.</p>
58
57
59
58
<h2>Properties</h2>
60
59
<p>The <code>static properties</code> object declares which HTML attributes the component observes, along with their type for coercion. The browser's <code>observedAttributes</code> list is auto-derived from the property names — you never write it by hand.</p>
61
60
62
61
<pre>class UserCard extends WebComponent {
63
-
static tag = 'user-card';
64
62
65
63
static properties = {
66
64
name: { type: String },
@@ -85,7 +83,7 @@ export default function Home() {
<p>When an attribute changes on the DOM element, webjs coerces the string value to the declared type:</p>
@@ -105,7 +103,6 @@ UserCard.register();</pre>
105
103
<p>For internal, non-attribute state, use <code>this.state</code> and <code>this.setState()</code>. This pattern will feel familiar if you have used React class components.</p>
<p>Light DOM is the default because global CSS and Tailwind utility classes apply directly — no <code>:host</code>, no <code>::part</code>, no CSS-variable plumbing. The browser renders a plain custom element with normal children. This is the mode the blog example uses everywhere except when shadow DOM buys something specific.</p>
227
223
228
224
<pre>class AppCard extends WebComponent {
229
-
static tag = 'app-card';
230
225
// static shadow = false is the default — no need to declare it.
231
226
static properties = {
232
227
heading: { type: String },
@@ -243,14 +238,13 @@ static styles = css\`
243
238
\`;
244
239
}
245
240
}
246
-
AppCard.register();</pre>
241
+
customElements.define('app-card', AppCard);</pre>
247
242
248
243
<h3>Class-prefix rule for custom CSS</h3>
249
244
<p>Tailwind utilities are unique by construction, so most light-DOM components need zero custom CSS. If you <em>do</em> author a <code><style></code> block or import a stylesheet, <strong>every class selector MUST be prefixed with the component's tag name</strong>. Otherwise two components with a <code>.card</code> or <code>.header</code> class will style each other.</p>
250
245
251
246
<pre>// Pattern A — BEM-ish class names prefixed with tag
252
247
class MyCard extends WebComponent {
253
-
static tag = 'my-card';
254
248
render() {
255
249
return html\`
256
250
<style>
@@ -264,7 +258,6 @@ class MyCard extends WebComponent {
264
258
265
259
// Pattern B — descendant selector rooted at the tag
266
260
class MyCard extends WebComponent {
267
-
static tag = 'my-card';
268
261
render() {
269
262
return html\`
270
263
<style>
@@ -285,7 +278,6 @@ class MyCard extends WebComponent {
@@ -299,7 +291,7 @@ class MyCard extends WebComponent {
299
291
\`;
300
292
}
301
293
}
302
-
Card.register();</pre>
294
+
customElements.define('my-card', Card);
303
295
304
296
<p><code>static styles</code> on a light-DOM component is silently ignored — there's no shadow root to adopt them into. If you see your styles failing, check whether you forgot <code>static shadow = true</code>.</p>
305
297
@@ -326,7 +318,6 @@ Card.register();</pre>
326
318
327
319
<pre>// Component definition
328
320
class AppShell extends WebComponent {
329
-
static tag = 'app-shell';
330
321
// ...
331
322
render() {
332
323
return html\`
@@ -350,7 +341,6 @@ html\`
350
341
<p>Use <code><slot name="..."></code> to route different pieces of content to different parts of a component:</p>
351
342
352
343
<pre>class PageLayout extends WebComponent {
353
-
static tag = 'page-layout';
354
344
static styles = css\`
355
345
.sidebar { float: left; width: 200px; }
356
346
.content { margin-left: 220px; }
@@ -371,7 +361,7 @@ html\`
371
361
\`;
372
362
}
373
363
}
374
-
PageLayout.register();
364
+
customElements.define('page-layout', PageLayout);
375
365
376
366
// Usage: assign content to named slots with the slot="" attribute
377
367
html\`
@@ -491,25 +481,20 @@ render() {
491
481
492
482
<p>During SSR, <code>?disabled=\${true}</code> emits <code>disabled=""</code> and <code>?disabled=\${false}</code> emits nothing — matching how the browser interprets boolean attributes.</p>
493
483
494
-
<h2>register()</h2>
495
-
<p>Every component must call <code>register()</code> after its class definition. This static method does two things:</p>
496
-
497
-
<pre>MyCounter.register();</pre>
484
+
<h2>customElements.define()</h2>
485
+
<p>Register the component with the web-standard <code>customElements.define()</code> API at the bottom of the file:</p>
498
486
499
-
<ol>
500
-
<li><strong>Registers with <code>customElements.define()</code></strong> — on the browser, this tells the browser to upgrade all <code><my-counter></code> elements with the <code>MyCounter</code> class. On the server, it stores the class in an internal registry so <code>renderToString</code> can look it up.</li>
501
-
<li><strong>Stores the module URL</strong> — passing <code>import.meta.url</code> lets the SSR shell emit <code><link rel="modulepreload"></code> hints for the component's JavaScript file. This eliminates a network round-trip: the browser starts fetching the module <strong>before</strong> the HTML parser encounters the custom element tag, so the component upgrades faster.</li>
<p>You can omit <code>import.meta.url</code> and just call <code>MyCounter.register()</code>, but you lose the modulepreload optimization.</p>
505
-
506
-
<pre>// With module URL — recommended
507
-
Counter.register();
489
+
<p>webjs wraps the native API (and installs a compatible shim on the server) so the same line works in both environments:</p>
490
+
<ul>
491
+
<li><strong>Browser</strong> — tells the browser to upgrade all <code><my-counter></code> elements with the <code>MyCounter</code> class, and mirrors the mapping into webjs's internal registry.</li>
492
+
<li><strong>Server</strong> — stores the class in the internal registry so <code>renderToString</code> can look it up for Declarative Shadow DOM injection.</li>
493
+
</ul>
508
494
509
-
// Without module URL — works but no modulepreload hint
510
-
Counter.register();</pre>
495
+
<p>Module URLs for <code><link rel="modulepreload"></code> hints are discovered separately, by a server-side scanner that walks the app tree at boot and derives the file path for each discovered tag. No per-component <code>import.meta.url</code> argument needed.</p>
511
496
512
-
<blockquote>Always call <code>register()</code> at the module's top level, outside the class body. This ensures the component is registered as soon as the module is imported, both on server and client.</blockquote>
497
+
<blockquote>Always call <code>customElements.define</code> at the module's top level, outside the class body. The component registers as soon as the module is imported, both on server and client.</blockquote>
513
498
514
499
<h2>Server Rendering</h2>
515
500
<p>webjs components are server-rendered using <strong>Declarative Shadow DOM</strong>. When the server renders a page containing <code><my-counter count="5"></my-counter></code>, the output looks like:</p>
@@ -528,7 +513,7 @@ Counter.register();</pre>
528
513
529
514
<h3>How SSR Works</h3>
530
515
<ul>
531
-
<li>The server imports the component module, which calls <code>register()</code> and stores the class in the registry.</li>
516
+
<li>The server imports the component module, which calls <code>customElements.define()</code> and stores the class in the registry.</li>
532
517
<li>During <code>renderToString()</code>, the server scans the output HTML for registered custom element tags.</li>
533
518
<li>For each match, it creates a temporary instance, applies attributes from the HTML, calls <code>render()</code>, and wraps the result in a <code><template shadowrootmode="open"></code> block with the component's styles.</li>
534
519
<li>The browser parses this as a native declarative shadow root — the content is visible <strong>before any JavaScript loads</strong>.</li>
@@ -539,7 +524,6 @@ Counter.register();</pre>
539
524
<p>On the server, <code>render()</code> can be async. This lets you fetch data inside a component:</p>
<p>On the client, <code>render()</code> is called synchronously. If you need async data on the client, fetch it in <code>connectedCallback()</code> and call <code>setState()</code> when the data arrives.</p>
559
543
@@ -584,7 +568,6 @@ UserProfile.register();</pre>
584
568
<pre>import { WebComponent, html, css, repeat } from 'webjs';
585
569
586
570
class TaskList extends WebComponent {
587
-
static tag = 'task-list';
588
571
589
572
constructor() {
590
573
super();
@@ -622,7 +605,7 @@ class TaskList extends WebComponent {
<li><strong>Register</strong> with <code>ClassName.register()</code>.</li>
692
+
<li><strong>Register</strong> with <code>customElements.define('tag', ClassName)</code>.</li>
711
693
<li><strong>State</strong> — use <code>this.setState({...})</code> for shallow merge + batched re-render.</li>
712
694
<li><strong>Events</strong> — <code>@click</code>, <code>@submit</code>, <code>@input</code> in templates. Stable dispatchers, no listener churn.</li>
713
695
<li><strong>Bindings</strong> — <code>attr=\${v}</code> for attributes, <code>.prop=\${v}</code> for properties, <code>?bool=\${v}</code> for booleans.</li>
0 commit comments