atoms-element v5.0.0

#js

git clone https://git.pyrossh.dev/atoms-element

A simple web component library for defining your custom elements. It works on both client and server.


ba74771 — Peter John 2026-08-07T16:03:34+05:30
improve components

Generated files not shown: package-lock.json

examples/e2e.spec.js CHANGED
@@ -49,7 +49,18 @@ test('renders each counter exactly once, with no leftover duplicate SSR markup',
49
49
  }
50
50
  });
51
51
 
52
+ test('seeds each counter from its count attribute, and app-total sums them with no manual sync', async () => {
53
+ const first = page.locator('app-counter').nth(0);
54
+ const second = page.locator('app-counter').nth(1);
55
+ assert.strictEqual((await first.locator('h1').textContent()).trim(), '5', 'first app-counter has count="5"');
56
+ assert.strictEqual((await second.locator('h1').textContent()).trim(), '7', 'second app-counter has count="7"');
57
+ // computed server-side by cheerio-parsing the already-rendered preceding
58
+ // markup (both app-counter tags), via useAttr — not a separately-tracked
59
+ // total, and no shared store between the two components.
60
+ assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 12');
61
+ });
62
+
52
- test('clicking +/- updates the counter and the shared total reducer', async () => {
63
+ test('clicking +/- updates the counter and the derived total', async () => {
53
64
  const first = page.locator('app-counter').nth(0);
54
65
  const second = page.locator('app-counter').nth(1);
55
66
 
@@ -59,9 +70,26 @@ test('clicking +/- updates the counter and the shared total reducer', async () =
59
70
  await second.getByRole('button', { name: '+' }).click();
60
71
  await first.getByRole('button', { name: '-' }).click();
61
72
 
73
+ // first: 5 + 3 - 1 = 7, second: 7 + 1 = 8, total: sum of both, live. Reflection
74
+ // and the MutationObserver it feeds are both async, so wait for the total to
75
+ // actually settle rather than asserting immediately after the last click.
76
+ await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 15');
62
- assert.strictEqual((await first.locator('h1').textContent()).trim(), '2');
77
+ assert.strictEqual((await first.locator('h1').textContent()).trim(), '7');
63
- assert.strictEqual((await second.locator('h1').textContent()).trim(), '1');
78
+ assert.strictEqual((await second.locator('h1').textContent()).trim(), '8');
79
+ assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 15');
80
+ });
81
+
82
+ test('external attribute writes are adopted directly, with no separate internal state to conflict with', async () => {
83
+ // Continuing from the previous test, the first counter currently shows 7.
84
+ // count now lives only in the attribute (via useProp) — there's no reducer
85
+ // value to reconcile it against, so an external write just is the new
86
+ // state, and app-total (via useAttr) picks it up automatically.
87
+ const first = page.locator('app-counter').nth(0);
88
+ await first.evaluate((el) => el.setAttribute('count', '30'));
89
+ await page.waitForFunction(() => document.querySelector('app-counter').querySelector('h1').textContent.trim() === '30');
90
+ assert.strictEqual((await first.locator('h1').textContent()).trim(), '30');
91
+ await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 38');
64
- assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 3');
92
+ assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 38');
65
93
  });
66
94
 
67
95
  test('no console/page errors were thrown', () => {
examples/elements/app-counter.js CHANGED
@@ -1,68 +1,23 @@
1
- import { useEffect } from '../../index.js';
2
- import { createElement, css, html, useReducer } from '../../index.js';
1
+ import { createElement, css, html, useProp } from '../../index.js';
3
- import { totalReducer } from '../store.js';
4
2
 
3
+ // State lives only in the count attribute — useProp reads it (type-coerced to
4
+ // a number, since the default 0 is a number) and writes it straight back via
5
+ // setAttribute, with no separate internal copy to keep in sync. count still
6
+ // has to appear as a destructured param below for it to end up in
7
+ // observedAttributes; useProp doesn't add it there itself.
5
- const Counter = ({ name, meta }) => {
8
+ const Counter = ({ name, count }) => {
6
- const { count, actions, effects } = useReducer({
7
- initial: {
8
- count: 0,
9
- data: undefined,
10
- err: null,
11
- },
12
- reducer: {
13
- increment: (state) => {
14
- state.count += 1;
15
- },
16
- decrement: (state) => {
17
- state.count -= 1;
18
- },
19
- setLoading: (state, v) => {
20
- state.loading = v;
21
- },
22
- setData: (state, data) => {
23
- state.data = data;
24
- },
25
- setErr: (state, err) => {
26
- state.err = err;
27
- },
28
- },
29
- effects: {
30
- loadData: async (actions, id) => {
31
- try {
32
- actions.setLoading(true);
33
- const res = await fetch(`/api/posts/${id}`);
9
+ const [currentCount, setCount] = useProp('count', 0);
34
- actions.setData(await res.json());
35
- } catch (err) {
36
- actions.setErr(err);
37
- } finally {
38
- actions.setLoading(false);
39
- }
40
- },
41
- },
42
- });
43
- useEffect(() => {
44
- effects.loadData(name);
45
- }, []);
46
- const increment = () => {
10
+ const increment = () => setCount((c) => c + 1);
47
- actions.increment();
48
- totalReducer.actions.increment(count + 1);
49
- };
50
- const decrement = () => {
11
+ const decrement = () => setCount((c) => c - 1);
51
- actions.decrement();
52
- totalReducer.actions.decrement(count - 1);
53
- };
54
- const warningClass = count > 10 ? 'warning' : '';
12
+ const warningClass = currentCount > 10 ? 'warning' : '';
55
13
 
56
14
  return html`
57
15
  <div class="heading">
58
- <div class="label">
16
+ <div class="label">Counter: ${name}</div>
59
- Counter: ${name}
60
- <span>starts at ${meta?.start}</span>
61
- </div>
62
17
  <div class="controls">
63
18
  <button @click=${decrement}>-</button>
64
19
  <div class="count">
65
- <h1 class="${warningClass}">${count}</h1>
20
+ <h1 class="${warningClass}">${currentCount}</h1>
66
21
  </div>
67
22
  <button @click=${increment}>+</button>
68
23
  </div>
examples/elements/app-total.js CHANGED
@@ -1,8 +1,11 @@
1
- import { createElement, css, html, useReducer } from '../../index.js';
1
+ import { createElement, css, html, useAttr } from '../../index.js';
2
- import { totalReducer } from '../store.js';
3
2
 
4
3
  const Total = () => {
4
+ // No shared store: reads the count attribute directly off every app-counter
5
+ // on the page. Server-side this parses the already-rendered preceding
6
+ // markup; client-side it's backed by a MutationObserver, set up once.
7
+ const counts = useAttr('app-counter', 'count');
5
- const { total } = useReducer(totalReducer);
8
+ const total = counts.reduce((sum, v) => sum + (Number(v) || 0), 0);
6
9
  return html`
7
10
  <div>
8
11
  <h1>Total of 2 Counters: ${total}</h1>
examples/pages/index.js CHANGED
@@ -21,8 +21,8 @@ const head = ({ config }) => {
21
21
  const body = () => {
22
22
  return staticHtml`
23
23
  <div class="page">
24
- <app-counter name="1" meta="{'start': 5}"></app-counter>
24
+ <app-counter name="1" count="5"></app-counter>
25
- <app-counter name="2" meta="{'start': 7}"></app-counter>
25
+ <app-counter name="2" count="7"></app-counter>
26
26
  <app-total></app-total>
27
27
  </div>
28
28
  `;
examples/server.js CHANGED
@@ -12,7 +12,6 @@ const port = process.argv[2] || 3000;
12
12
  const elements = ['app-counter.js', 'app-total.js'];
13
13
  const srcMap = {
14
14
  '/index.js': `${rootDir}/index.js`,
15
- '/store.js': `${__dirname}/store.js`,
16
15
  };
17
16
  elements.forEach((el) => {
18
17
  srcMap['/elements/' + el] = `${__dirname}/elements/${el}`;
examples/store.js DELETED
@@ -1,15 +0,0 @@
1
- import { createReducer } from '../index.js';
2
-
3
- export const totalReducer = createReducer({
4
- initial: {
5
- total: 0,
6
- },
7
- reducer: {
8
- increment: (state) => {
9
- state.total += 1;
10
- },
11
- decrement: (state) => {
12
- state.total -= 1;
13
- },
14
- },
15
- });
index.d.ts CHANGED
@@ -69,3 +69,16 @@ export function staticHtml(strings: TemplateStringsArray, ...values: any[]): any
69
69
  // (e.g. meta=${jsonAttr(data)}) so it round-trips through this library's
70
70
  // attribute-parsing convention instead of being stringified as [object Object].
71
71
  export function jsonAttr(value: any): unknown;
72
+ // Reads `attribute` off every element matching `selector` — server-side via
73
+ // cheerio over the already-rendered preceding markup, client-side via a
74
+ // MutationObserver. Returns the raw attribute strings; parse them yourself.
75
+ export function useAttr(selector: string, attribute: string): string[];
76
+ // The element instance itself (e.g. to call host.setAttribute(...)). Only
77
+ // call this during render — capture the result via closure for later use.
78
+ export function useHost(): any;
79
+ // State lives only in the named attribute — no separate internal copy. `name`
80
+ // must also appear as a destructured render-function parameter for it to end
81
+ // up in observedAttributes. defaultValue's type controls how the attribute
82
+ // string is parsed (number/boolean/string); the setter accepts a value or an
83
+ // updater function, always resolved against the live attribute at call time.
84
+ export function useProp<T>(name: string, defaultValue: T): [T, (next: T | ((prev: T) => T)) => void];
index.js CHANGED
@@ -13,11 +13,20 @@ import { create } from 'mutative';
13
13
  const isBrowser = typeof window !== 'undefined';
14
14
  export { html, isBrowser, unsafeHTML, staticHtml };
15
15
 
16
- // @lit-labs/ssr pulls in Node-only dependencies (module resolution, fetch
16
+ // @lit-labs/ssr and cheerio both pull in Node-only dependencies (module
17
- // polyfills), so it can only ever be imported on the server. This project's
17
+ // resolution, fetch polyfills), so they can only ever be imported on the
18
- // index.js is loaded directly in the browser too (no bundler), so the import
18
+ // server. This project's index.js is loaded directly in the browser too (no
19
- // is conditional and dynamic — in the browser this line never executes.
19
+ // bundler), so these imports are conditional and dynamic — in the browser
20
+ // these lines never execute.
20
21
  const ssr = isBrowser ? null : await import('@lit-labs/ssr');
22
+ const cheerio = isBrowser ? null : await import('cheerio');
23
+
24
+ // Set by expandCustomElements right before rendering each matched component,
25
+ // to whatever HTML has already been generated before it in the current page
26
+ // (i.e. its preceding siblings, already expanded) — this is what lets useAttr
27
+ // answer "what does this other, already-rendered element's attribute say"
28
+ // during SSR, where there's no live DOM to query.
29
+ const ssrContext = { precedingHtml: '' };
21
30
 
22
31
  const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;
23
32
  const voidElements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
@@ -88,6 +97,28 @@ const parseTag = (tag) => {
88
97
  };
89
98
  const parseAttrValue = (value) => (value && value.startsWith('{') ? JSON.parse(value.replace(/'/g, '"')) : value);
90
99
 
100
+ // Type is inferred from defaultValue, the way useProp's own default argument
101
+ // works: a number default means parse as a number, a boolean default means
102
+ // parse as a boolean, otherwise pass the string through as-is. Anything
103
+ // already parsed upstream (an object/array, via parseAttrValue) is returned
104
+ // unchanged.
105
+ const coerceAttr = (raw, defaultValue) => {
106
+ if (raw === undefined || raw === null) {
107
+ return defaultValue;
108
+ }
109
+ if (typeof raw !== 'string') {
110
+ return raw;
111
+ }
112
+ if (typeof defaultValue === 'number') {
113
+ return Number(raw);
114
+ }
115
+ if (typeof defaultValue === 'boolean') {
116
+ return raw !== 'false' && raw !== '0';
117
+ }
118
+ return raw;
119
+ };
120
+ const serializeAttr = (value) => (typeof value === 'object' && value !== null ? JSON.stringify(value).replace(/"/g, `'`) : String(value));
121
+
91
122
  class JsonAttrDirective extends Directive {
92
123
  render(value) {
93
124
  return JSON.stringify(value).replace(/"/g, `'`);
@@ -122,7 +153,8 @@ const expandCustomElements = (htmlString) => {
122
153
  return htmlString;
123
154
  }
124
155
  const re = new RegExp(`<(${tagNames.join('|')})(?=[\\s>])([^>]*)>\\s*</\\1>`, 'g');
125
- return htmlString.replace(re, (match, tagName, attrsPortion) => {
156
+ return htmlString.replace(re, (match, tagName, attrsPortion, offset) => {
157
+ ssrContext.precedingHtml = htmlString.slice(0, offset);
126
158
  const { attrs } = parseTag(`<${tagName}${attrsPortion}>`);
127
159
  Object.keys(attrs).forEach((key) => {
128
160
  attrs[key] = parseAttrValue(attrs[key]);
@@ -290,6 +322,7 @@ const task = (flush) => {
290
322
  }
291
323
  };
292
324
  const depsChanged = (prev, next) => prev == null || next.some((f, i) => !Object.is(f, prev[i]));
325
+ const shallowArrayEqual = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
293
326
 
294
327
  export const createAttrs = (attrs) => attrs;
295
328
 
@@ -354,6 +387,12 @@ export const useReducer = (reducer, selector = identity) => {
354
387
  const state = comp.hooks.data[index].getValue();
355
388
  return { ...state, actions: comp.hooks.data[index].actions, effects: comp.hooks.data[index].effects };
356
389
  };
390
+ // The element instance itself, e.g. to reflect state onto its own attributes
391
+ // (host.setAttribute(...)) so other components can read it via useAttr. Safe to
392
+ // call only during render — capture the return value via closure for later use
393
+ // in effects/handlers, don't call this hook itself from inside one.
394
+ export const useHost = () => currentComponent.get();
395
+
357
396
  export const useEffect = (fn, deps) => {
358
397
  const comp = currentComponent.get();
359
398
  const index = comp.hooks.index++;
@@ -363,6 +402,64 @@ export const useEffect = (fn, deps) => {
363
402
  }
364
403
  };
365
404
 
405
+ // Reads `attribute` off every element matching `selector`, without either side
406
+ // needing a shared store: on the server there's no live DOM to query, so it
407
+ // reads from ssrContext.precedingHtml (everything already rendered before this
408
+ // component in the current page) via cheerio; in the browser it sets up a
409
+ // MutationObserver once and re-renders this component whenever a match changes.
410
+ export const useAttr = (selector, attribute) => {
411
+ const comp = currentComponent.get();
412
+ const index = comp.hooks.index++;
413
+ if (!isBrowser) {
414
+ const $ = cheerio.load(ssrContext.precedingHtml);
415
+ return $(selector)
416
+ .map((i, el) => $(el).attr(attribute))
417
+ .get();
418
+ }
419
+ if (!comp.hooks.data[index]) {
420
+ const read = () => Array.from(document.querySelectorAll(selector)).map((el) => el.getAttribute(attribute));
421
+ comp.hooks.data[index] = read();
422
+ const observer = new window.MutationObserver(() => {
423
+ const next = read();
424
+ if (!shallowArrayEqual(next, comp.hooks.data[index])) {
425
+ comp.hooks.data[index] = next;
426
+ comp.update();
427
+ }
428
+ });
429
+ observer.observe(document.body, { attributes: true, attributeFilter: [attribute], subtree: true });
430
+ comp.hooks.cleanups[index] = () => observer.disconnect();
431
+ }
432
+ return comp.hooks.data[index];
433
+ };
434
+
435
+ // The attribute is the only copy of this state — there's no separate internal
436
+ // value to keep in sync with it (that bidirectional sync, tried in an earlier
437
+ // version of this, was a genuine, unresolvable race: whichever side changed
438
+ // last could always be undone by a stale update from the other side still in
439
+ // flight). Reading here is just "what does the attribute say right now";
440
+ // writing is just "set the attribute" (which synchronously updates comp.attrs
441
+ // via attributeChangedCallback and schedules a re-render if connected).
442
+ //
443
+ // The setter accepts a plain value or an updater function (setCount(c => c+1))
444
+ // — always resolved against the live attribute value at call time, not a
445
+ // value captured in a render closure, so rapid successive calls (e.g. quick
446
+ // clicks, each queued before a re-render lands) don't read stale state and
447
+ // silently lose updates.
448
+ //
449
+ // name must still appear as a destructured render-function parameter (even if
450
+ // unused otherwise) for it to end up in observedAttributes — that list is read
451
+ // by the browser once, synchronously, when the class is defined, so it can't
452
+ // depend on anything this hook (called later, during render) discovers.
453
+ export const useProp = (name, defaultValue) => {
454
+ const comp = currentComponent.get();
455
+ const value = coerceAttr(comp.attrs[name], defaultValue);
456
+ const setValue = (next) => {
457
+ const resolved = typeof next === 'function' ? next(coerceAttr(comp.attrs[name], defaultValue)) : next;
458
+ comp.setAttribute(name, serializeAttr(resolved));
459
+ };
460
+ return [value, setValue];
461
+ };
462
+
366
463
  const registry = {};
367
464
  const injectedStyleTags = new Set();
368
465
  export const getElement = (name) => registry[name];
@@ -381,6 +478,7 @@ export const createElement = (meta, renderFn) => {
381
478
  constructor(ssrAttrs) {
382
479
  super();
383
480
  this._dirty = false;
481
+ this._effectsPending = false;
384
482
  this._connected = false;
385
483
  this._hydrated = false;
386
484
  this.attrs = ssrAttrs || {};
@@ -465,7 +563,20 @@ export const createElement = (meta, renderFn) => {
465
563
  }
466
564
 
467
565
  enqueueEffects() {
566
+ // Without this guard, several render cycles completing in quick succession
567
+ // (e.g. rapid clicks) each schedule their own separate effects-flush task;
568
+ // those can interleave with later renders' own effect scheduling in ways
569
+ // that break invariants effects rely on (observed this firsthand: a
570
+ // still-pending flush from an earlier render raced a newer one and
571
+ // clobbered state that had already moved on).
572
+ if (this._effectsPending) {
573
+ return;
574
+ }
575
+ this._effectsPending = true;
468
- this.batch(task, filo, () => this._flushEffects());
576
+ this.batch(task, filo, () => {
577
+ this._effectsPending = false;
578
+ this._flushEffects();
579
+ });
469
580
  }
470
581
 
471
582
  render() {
package.json CHANGED
@@ -41,6 +41,7 @@
41
41
  "dependencies": {
42
42
  "@lit-labs/ssr": "^4.1.0",
43
43
  "@lit-labs/ssr-client": "^1.1.8",
44
+ "cheerio": "^1.2.0",
44
45
  "lit-html": "^3.3.3",
45
46
  "mutative": "^1.3.0"
46
47
  }