Skip to content

Commit bb61186

Browse files
committed
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.
1 parent 7d1a27b commit bb61186

52 files changed

Lines changed: 480 additions & 485 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ import { html, css, WebComponent, render, renderToString } from 'webjs';
308308
| `html` | Tagged template literal producing a `TemplateResult`. Use in pages, layouts, and component `render()`. |
309309
| `css` | Tagged template literal producing a `CSSResult`. Assign to `static styles` on components. |
310310
| `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)`. |
312312
| `render(v, el)` | Client-side: render a value into a DOM element. |
313313
| `renderToString` | Server-side: **async** — render a value to an HTML string with DSD injection. Awaits Promise-valued holes and async component `render()` methods. |
314314
| `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**.
412412

413413
```js
414414
class MyThing extends WebComponent {
415-
static tag = 'my-thing'; // required
416415
static shadow = false; // default: light DOM. Set true for scoped shadow DOM
417416
static lazy = false; // true = load module on viewport entry (IntersectionObserver)
418417
static properties = { // attribute → property coercion
@@ -432,7 +431,7 @@ class MyThing extends WebComponent {
432431
return html``;
433432
}
434433
}
435-
MyThing.register();
434+
customElements.define('my-thing', MyThing);
436435
```
437436

438437
Mutate state with `this.setState({...})` — it batches a re-render via microtask.
@@ -451,14 +450,13 @@ accessor the framework installs via `Object.defineProperty`.
451450
import { WebComponent, html } from 'webjs';
452451
453452
class StudentCard extends WebComponent {
454-
static tag = 'student-card';
455453
static properties = { student: { type: Object } }; // runtime: tracked + coerced
456454
declare student: Student; // compile-time: typed
457455
render() {
458456
return html`<p>${this.student.name}</p>`;
459457
}
460458
}
461-
StudentCard.register();
459+
customElements.define('student-card', StudentCard);
462460
```
463461

464462
Built-in constructors (`String`, `Number`, `Boolean`, `Array`, `Object`)
@@ -569,7 +567,6 @@ Pick one of these two patterns and stick to it per component:
569567
```ts
570568
// Pattern A — BEM-ish class names prefixed with tag
571569
class MyCard extends WebComponent {
572-
static tag = 'my-card';
573570
render() {
574571
return html`
575572
<style>
@@ -585,7 +582,6 @@ class MyCard extends WebComponent {
585582
586583
// Pattern B — descendant selector rooted at the tag
587584
class MyCard extends WebComponent {
588-
static tag = 'my-card';
589585
render() {
590586
return html`
591587
<style>
@@ -802,7 +798,7 @@ When you mark an action as `expose('METHOD /path', fn)`, you are declaring it pa
802798
803799
### Components (`components/*.js`)
804800
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.
806802
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.
807803
- Imported by pages (for SSR) and/or other components (for composition).
808804
- **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.
956952
957953
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.
958954
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)`.
960956
4. **Event (`@`), property (`.`), and boolean (`?`) holes in `html` must be unquoted** — e.g. `@click=${fn}`, never `@click="${fn}"`.
961957
5. **Do not mutate `this.state` directly** — use `setState`. State reads are fine.
962958
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 });
10441040
// components/hello-world.js
10451041
import { WebComponent, html } from 'webjs';
10461042
export class HelloWorld extends WebComponent {
1047-
static tag = 'hello-world';
10481043
render() { return html`<p>Hello!</p>`; }
10491044
}
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.
10541046

10551047
### Scaffold commands
10561048

@@ -1630,8 +1622,8 @@ webjs check # validate app against conventions
16301622
webjs check --rules # list all rules
16311623
```
16321624
1633-
Checks for: actions in modules, one-function-per-action, components have
1634-
`.register()`, no server imports in client code, tests exist for modules,
1625+
Checks for: actions in modules, one-function-per-action, components call
1626+
`customElements.define`, no server imports in client code, tests exist for modules,
16351627
tag names have hyphens. Override any rule in `package.json`:
16361628
16371629
```json

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ export default async function Home() {
8585
import { WebComponent, html } from 'webjs';
8686

8787
export class Counter extends WebComponent {
88-
static tag = 'my-counter';
8988
// Light DOM is the default; Tailwind utility classes apply directly.
9089
static properties = { count: { type: Number } };
9190
count = 0;
@@ -100,7 +99,7 @@ export class Counter extends WebComponent {
10099
`;
101100
}
102101
}
103-
Counter.register();
102+
customElements.define('my-counter', Counter);
104103
```
105104

106105
Need scoped styles, `<slot>` projection, or embed-ready isolation? Opt

docs/app/docs/components/page.ts

Lines changed: 26 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export default function Components() {
1313
<pre>import { WebComponent, html, css } from 'webjs';
1414
1515
class MyCounter extends WebComponent {
16-
static tag = 'my-counter';
1716
1817
static properties = {
1918
count: { type: Number },
@@ -36,7 +35,7 @@ class MyCounter extends WebComponent {
3635
}
3736
}
3837
39-
MyCounter.register();</pre>
38+
customElements.define('my-counter', MyCounter);</pre>
4039
4140
<p>That is a complete, working component. Import it from a page or layout and use it like any HTML element:</p>
4241
@@ -47,20 +46,19 @@ export default function Home() {
4746
}</pre>
4847
4948
<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>&lt;my-counter&gt;</code> from built-in elements like <code>&lt;div&gt;</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>&lt;my-counter&gt;</code> from built-in elements like <code>&lt;div&gt;</code>. Register the component with the standard <code>customElements.define()</code> API at the bottom of the file:</p>
5150
5251
<pre>class UserCard extends WebComponent {
53-
static tag = 'user-card'; // must contain a hyphen
5452
// ...
55-
}</pre>
53+
}
54+
customElements.define('user-card', UserCard);</pre>
5655
57-
<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>
5857
5958
<h2>Properties</h2>
6059
<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>
6160
6261
<pre>class UserCard extends WebComponent {
63-
static tag = 'user-card';
6462
6563
static properties = {
6664
name: { type: String },
@@ -85,7 +83,7 @@ export default function Home() {
8583
\`;
8684
}
8785
}
88-
UserCard.register();</pre>
86+
customElements.define('user-card', UserCard);</pre>
8987
9088
<h3>Attribute-to-Property Coercion</h3>
9189
<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>
105103
<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>
106104
107105
<pre>class TodoList extends WebComponent {
108-
static tag = 'todo-list';
109106
110107
constructor() {
111108
super();
@@ -146,7 +143,7 @@ UserCard.register();</pre>
146143
\`;
147144
}
148145
}
149-
TodoList.register();</pre>
146+
customElements.define('todo-list', TodoList);</pre>
150147
151148
<h3>How setState Works</h3>
152149
<ul>
@@ -165,7 +162,6 @@ this.setState({ label: 'hello' });
165162
<pre>import { WebComponent, html, css } from 'webjs';
166163
167164
class StyledCard extends WebComponent {
168-
static tag = 'styled-card';
169165
static styles = css\`
170166
:host {
171167
display: block;
@@ -189,7 +185,7 @@ class StyledCard extends WebComponent {
189185
\`;
190186
}
191187
}
192-
StyledCard.register();</pre>
188+
customElements.define('styled-card', StyledCard);</pre>
193189
194190
<h3>How Styles Are Applied</h3>
195191
<ul>
@@ -226,7 +222,6 @@ static styles = css\`
226222
<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>
227223
228224
<pre>class AppCard extends WebComponent {
229-
static tag = 'app-card';
230225
// static shadow = false is the default — no need to declare it.
231226
static properties = {
232227
heading: { type: String },
@@ -243,14 +238,13 @@ static styles = css\`
243238
\`;
244239
}
245240
}
246-
AppCard.register();</pre>
241+
customElements.define('app-card', AppCard);</pre>
247242
248243
<h3>Class-prefix rule for custom CSS</h3>
249244
<p>Tailwind utilities are unique by construction, so most light-DOM components need zero custom CSS. If you <em>do</em> author a <code>&lt;style&gt;</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>
250245
251246
<pre>// Pattern A — BEM-ish class names prefixed with tag
252247
class MyCard extends WebComponent {
253-
static tag = 'my-card';
254248
render() {
255249
return html\`
256250
&lt;style&gt;
@@ -264,7 +258,6 @@ class MyCard extends WebComponent {
264258
265259
// Pattern B — descendant selector rooted at the tag
266260
class MyCard extends WebComponent {
267-
static tag = 'my-card';
268261
render() {
269262
return html\`
270263
&lt;style&gt;
@@ -285,7 +278,6 @@ class MyCard extends WebComponent {
285278
</ul>
286279
287280
<pre>class Card extends WebComponent {
288-
static tag = 'my-card';
289281
static shadow = true; // opt in
290282
static styles = css\`
291283
:host { display: block; padding: 16px; border: 1px solid var(--border); border-radius: 8px; }
@@ -299,7 +291,7 @@ class MyCard extends WebComponent {
299291
\`;
300292
}
301293
}
302-
Card.register();</pre>
294+
customElements.define('my-card', Card);
303295
304296
<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>
305297
@@ -326,7 +318,6 @@ Card.register();</pre>
326318
327319
<pre>// Component definition
328320
class AppShell extends WebComponent {
329-
static tag = 'app-shell';
330321
// ...
331322
render() {
332323
return html\`
@@ -350,7 +341,6 @@ html\`
350341
<p>Use <code>&lt;slot name="..."&gt;</code> to route different pieces of content to different parts of a component:</p>
351342
352343
<pre>class PageLayout extends WebComponent {
353-
static tag = 'page-layout';
354344
static styles = css\`
355345
.sidebar { float: left; width: 200px; }
356346
.content { margin-left: 220px; }
@@ -371,7 +361,7 @@ html\`
371361
\`;
372362
}
373363
}
374-
PageLayout.register();
364+
customElements.define('page-layout', PageLayout);
375365
376366
// Usage: assign content to named slots with the slot="" attribute
377367
html\`
@@ -491,25 +481,20 @@ render() {
491481
492482
<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>
493483
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>
498486
499-
<ol>
500-
<li><strong>Registers with <code>customElements.define()</code></strong> — on the browser, this tells the browser to upgrade all <code>&lt;my-counter&gt;</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>&lt;link rel="modulepreload"&gt;</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>
502-
</ol>
487+
<pre>customElements.define('my-counter', MyCounter);</pre>
503488
504-
<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>&lt;my-counter&gt;</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>
508494
509-
// Without module URL — works but no modulepreload hint
510-
Counter.register();</pre>
495+
<p>Module URLs for <code>&lt;link rel="modulepreload"&gt;</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>
511496
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>
513498
514499
<h2>Server Rendering</h2>
515500
<p>webjs components are server-rendered using <strong>Declarative Shadow DOM</strong>. When the server renders a page containing <code>&lt;my-counter count="5"&gt;&lt;/my-counter&gt;</code>, the output looks like:</p>
@@ -528,7 +513,7 @@ Counter.register();</pre>
528513
529514
<h3>How SSR Works</h3>
530515
<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>
532517
<li>During <code>renderToString()</code>, the server scans the output HTML for registered custom element tags.</li>
533518
<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>&lt;template shadowrootmode="open"&gt;</code> block with the component's styles.</li>
534519
<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>
539524
<p>On the server, <code>render()</code> can be async. This lets you fetch data inside a component:</p>
540525
541526
<pre>class UserProfile extends WebComponent {
542-
static tag = 'user-profile';
543527
static properties = { userId: { type: String } };
544528
545529
userId = '';
@@ -553,7 +537,7 @@ Counter.register();</pre>
553537
\`;
554538
}
555539
}
556-
UserProfile.register();</pre>
540+
customElements.define('user-profile', UserProfile);</pre>
557541
558542
<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>
559543
@@ -584,7 +568,6 @@ UserProfile.register();</pre>
584568
<pre>import { WebComponent, html, css, repeat } from 'webjs';
585569
586570
class TaskList extends WebComponent {
587-
static tag = 'task-list';
588571
589572
constructor() {
590573
super();
@@ -622,7 +605,7 @@ class TaskList extends WebComponent {
622605
\`;
623606
}
624607
}
625-
TaskList.register();</pre>
608+
customElements.define('task-list', TaskList);</pre>
626609
627610
<h3>How repeat() Works</h3>
628611
<ul>
@@ -642,7 +625,6 @@ TaskList.register();</pre>
642625
<pre>import { WebComponent, html, css, repeat, connectWS } from 'webjs';
643626
644627
class ChatBox extends WebComponent {
645-
static tag = 'chat-box';
646628
647629
static styles = css\`
648630
:host { display: block; border: 1px solid var(--border); border-radius: var(--rad-lg); }
@@ -701,13 +683,13 @@ class ChatBox extends WebComponent {
701683
\`;
702684
}
703685
}
704-
ChatBox.register();</pre>
686+
customElements.define('chat-box', ChatBox);</pre>
705687
706688
<h2>Quick Reference</h2>
707689
<ul>
708690
<li><strong>Extend</strong> <code>WebComponent</code> and set <code>static tag</code>, <code>static properties</code>, <code>static styles</code>.</li>
709691
<li><strong>Implement</strong> <code>render()</code> returning <code>html\`...\`</code>.</li>
710-
<li><strong>Register</strong> with <code>ClassName.register()</code>.</li>
692+
<li><strong>Register</strong> with <code>customElements.define('tag', ClassName)</code>.</li>
711693
<li><strong>State</strong> — use <code>this.setState({...})</code> for shallow merge + batched re-render.</li>
712694
<li><strong>Events</strong><code>@click</code>, <code>@submit</code>, <code>@input</code> in templates. Stable dispatchers, no listener churn.</li>
713695
<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

Comments
 (0)