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.


e955350Peter John 2026-08-07T19:46:46+05:30
release v5
.npmrc ADDED
@@ -0,0 +1 @@
1
+ //registry.npmjs.org/:_authToken=${NPM_TOKEN}
examples/e2e.spec.js CHANGED
@@ -45,18 +45,18 @@ test('renders each counter exactly once, with no leftover duplicate SSR markup',
45
45
  const counters = page.locator('app-counter');
46
46
  assert.strictEqual(await counters.count(), 2);
47
47
  for (let i = 0; i < 2; i++) {
48
- assert.strictEqual(await counters.nth(i).locator('h1').count(), 1, `counter ${i} should render exactly one <h1>`);
48
+ assert.strictEqual(await counters.nth(i).locator('output').count(), 1, `counter ${i} should render exactly one <output>`);
49
49
  }
50
50
  });
51
51
 
52
52
  test('seeds each counter from its count attribute, and app-total sums them with no manual sync', async () => {
53
53
  const first = page.locator('app-counter').nth(0);
54
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"');
55
+ assert.strictEqual((await first.locator('output').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"');
56
+ assert.strictEqual((await second.locator('output').textContent()).trim(), '7', 'second app-counter has count="7"');
57
57
  // computed server-side by cheerio-parsing the already-rendered preceding
58
- // markup (both app-counter tags), via useAttr — not a separately-tracked
58
+ // markup (both app-counter tags), via Total.watch — not a separately-
59
- // total, and no shared store between the two components.
59
+ // tracked total, and no shared store between the two components.
60
60
  assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 12');
61
61
  });
62
62
 
@@ -64,18 +64,20 @@ test('clicking +/- updates the counter and the derived total', async () => {
64
64
  const first = page.locator('app-counter').nth(0);
65
65
  const second = page.locator('app-counter').nth(1);
66
66
 
67
+ // Buttons carry an aria-label ("Increment"/"Decrement") that overrides their
68
+ // visible "+"/"-" glyph as the accessible name, so query by that instead.
67
- await first.getByRole('button', { name: '+' }).click();
69
+ await first.getByRole('button', { name: 'Increment' }).click();
68
- await first.getByRole('button', { name: '+' }).click();
70
+ await first.getByRole('button', { name: 'Increment' }).click();
69
- await first.getByRole('button', { name: '+' }).click();
71
+ await first.getByRole('button', { name: 'Increment' }).click();
70
- await second.getByRole('button', { name: '+' }).click();
72
+ await second.getByRole('button', { name: 'Increment' }).click();
71
- await first.getByRole('button', { name: '-' }).click();
73
+ await first.getByRole('button', { name: 'Decrement' }).click();
72
74
 
73
75
  // first: 5 + 3 - 1 = 7, second: 7 + 1 = 8, total: sum of both, live. Reflection
74
76
  // and the MutationObserver it feeds are both async, so wait for the total to
75
77
  // actually settle rather than asserting immediately after the last click.
76
78
  await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 15');
77
- assert.strictEqual((await first.locator('h1').textContent()).trim(), '7');
79
+ assert.strictEqual((await first.locator('output').textContent()).trim(), '7');
78
- assert.strictEqual((await second.locator('h1').textContent()).trim(), '8');
80
+ assert.strictEqual((await second.locator('output').textContent()).trim(), '8');
79
81
  assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 15');
80
82
  });
81
83
 
@@ -83,11 +85,11 @@ test('external attribute writes are adopted directly, with no separate internal
83
85
  // Continuing from the previous test, the first counter currently shows 7.
84
86
  // count now lives only in the attribute (via prop) — there's no reducer
85
87
  // value to reconcile it against, so an external write just is the new
86
- // state, and app-total (via useAttr) picks it up automatically.
88
+ // state, and app-total (via its watch declaration) picks it up automatically.
87
89
  const first = page.locator('app-counter').nth(0);
88
90
  await first.evaluate((el) => el.setAttribute('count', '30'));
89
- await page.waitForFunction(() => document.querySelector('app-counter').querySelector('h1').textContent.trim() === '30');
91
+ await page.waitForFunction(() => document.querySelector('app-counter').querySelector('output').textContent.trim() === '30');
90
- assert.strictEqual((await first.locator('h1').textContent()).trim(), '30');
92
+ assert.strictEqual((await first.locator('output').textContent()).trim(), '30');
91
93
  await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 38');
92
94
  assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 38');
93
95
  });
examples/elements/app-counter.js CHANGED
@@ -1,69 +1,68 @@
1
- import { createElement, css, html } from '../../index.js';
1
+ import { classMap, createElement, css, html } from '../../index.js';
2
2
 
3
3
  // State lives only in the count attribute — no separate internal copy to
4
- // keep in sync. Counter.attrTypes below declares count as a number: that
4
+ // keep in sync. Counter.attrs below declares count as a Number: that folds
5
- // folds it into observedAttributes on its own (name is still destructured
5
+ // it into observedAttributes on its own (name is still destructured as a
6
- // as a plain param since it's only ever displayed, never written), and
6
+ // plain param since it's only ever displayed, never written), and
7
7
  // createElement hands count to this function already wrapped as a
8
- // {value} getter/setter box, type-checked against the declared type.
8
+ // {value} getter/setter box, type-checked against the declared constructor.
9
9
  // count must be set in markup (no default) — reading count.value throws
10
10
  // otherwise. count.value += 1 reads the live attribute and writes straight
11
11
  // back via setAttribute.
12
+ //
13
+ // <output> is the element HTML actually defines for "the result of a
14
+ // calculation" — a live-updating count is exactly that, which is what lets
15
+ // the CSS below style it by tag name instead of an accessory class like the
16
+ // old .count.
12
17
  const Counter = ({ name, count }) => {
13
18
  const increment = () => { count.value += 1; };
14
19
  const decrement = () => { count.value -= 1; };
15
- const warningClass = count.value > 10 ? 'warning' : '';
16
20
 
17
21
  return html`
18
- <div class="heading">
19
- <div class="label">Counter: ${name}</div>
22
+ <p>Counter: ${name}</p>
20
- <div class="controls">
23
+ <div class="controls">
21
- <button @click=${decrement}>-</button>
24
+ <button type="button" aria-label="Decrement" @click=${decrement}>-</button>
22
- <div class="count">
23
- <h1 class="${warningClass}">${count.value}</h1>
25
+ <output class=${classMap({ warning: count.value > 10 })}>${count.value}</output>
24
- </div>
25
- <button @click=${increment}>+</button>
26
+ <button type="button" aria-label="Increment" @click=${increment}>+</button>
26
- </div>
27
27
  </div>
28
28
  `;
29
29
  };
30
30
 
31
- Counter.attrTypes = { count: 'number' };
31
+ Counter.attrs = { count: Number };
32
32
 
33
33
  Counter.styles = css`
34
34
  :scope {
35
35
  display: block;
36
36
  margin-top: 2.5rem;
37
+ color: rgb(55 65 81);
38
+ --color-danger: rgb(239 68 68);
39
+ --color-button-bg: rgb(209 213 219);
40
+ --color-button-bg-hover: rgb(229 231 235);
37
41
  }
38
- .heading {
39
- color: rgba(55, 65, 81, 1);
40
- }
41
- .label {
42
+ p {
42
- margin-bottom: 0.5rem;
43
+ margin: 0 0 0.5rem;
43
44
  }
44
45
  .controls {
45
46
  display: flex;
46
47
  align-items: center;
47
48
  }
48
- .count {
49
+ output {
49
50
  margin: 0 5rem;
50
- }
51
- .count h1 {
52
51
  font-size: 1.875rem;
53
52
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
54
53
  }
55
- .count h1.warning {
54
+ output.warning {
56
- color: rgba(239, 68, 68, 1);
55
+ color: var(--color-danger);
57
56
  }
58
57
  button {
59
- background-color: rgba(209, 213, 219, 1);
58
+ background-color: var(--color-button-bg);
60
- color: rgba(55, 65, 81, 1);
59
+ color: inherit;
61
60
  border-radius: 0.25rem;
62
61
  padding: 0.5rem 1rem;
63
62
  font-size: 1.875rem;
64
63
  }
65
64
  button:hover {
66
- background-color: rgba(229, 231, 235, 1);
65
+ background-color: var(--color-button-bg-hover);
67
66
  }
68
67
  button:focus {
69
68
  outline: none;
examples/elements/app-total.js CHANGED
@@ -1,10 +1,11 @@
1
- import { createElement, css, html, useAttr } from '../../index.js';
1
+ import { createElement, css, html } from '../../index.js';
2
2
 
3
+ // No shared store: Total.watch below declares "give me the count attribute
4
+ // off every app-counter on the page", and createElement resolves that into
5
+ // the counts array passed in here — server-side by parsing the already-
6
+ // rendered preceding markup, client-side via a MutationObserver it sets up
7
+ // and tears down on its own.
3
- const Total = () => {
8
+ const Total = ({ counts }) => {
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');
8
9
  const total = counts.reduce((sum, v) => sum + (Number(v) || 0), 0);
9
10
  return html`
10
11
  <div>
@@ -13,6 +14,8 @@ const Total = () => {
13
14
  `;
14
15
  };
15
16
 
17
+ Total.watch = { counts: { selector: 'app-counter', attribute: 'count' } };
18
+
16
19
  Total.styles = css`
17
20
  :scope {
18
21
  display: block;
examples/server.js CHANGED
@@ -17,12 +17,12 @@ elements.forEach((el) => {
17
17
  srcMap['/elements/' + el] = `${__dirname}/elements/${el}`;
18
18
  });
19
19
 
20
- // lit-html, @lit-labs/ssr-client and mutative are real npm dependencies (not
20
+ // lit-html and @lit-labs/ssr-client are real npm dependencies (not vendored),
21
- // vendored), so the browser needs an import map for every bare specifier
21
+ // so the browser needs an import map for every bare specifier reachable from
22
- // reachable from index.js — including ones used by their own internal files,
22
+ // index.js — including ones used by their own internal files, since the
23
- // since the import map is global to the page, not scoped per-package the way
23
+ // import map is global to the page, not scoped per-package the way Node's
24
- // Node's node_modules resolution is. The packages' files are served as-is;
24
+ // node_modules resolution is. The packages' files are served as-is; their
25
- // their own internal imports are all relative, so no other wiring is needed.
25
+ // own internal imports are all relative, so no other wiring is needed.
26
26
  const importMap = {
27
27
  imports: {
28
28
  'lit-html': '/node_modules/lit-html/lit-html.js',
@@ -31,9 +31,10 @@ const importMap = {
31
31
  'lit-html/directive-helpers.js': '/node_modules/lit-html/directive-helpers.js',
32
32
  'lit-html/private-ssr-support.js': '/node_modules/lit-html/private-ssr-support.js',
33
33
  'lit-html/directives/unsafe-html.js': '/node_modules/lit-html/directives/unsafe-html.js',
34
+ 'lit-html/directives/class-map.js': '/node_modules/lit-html/directives/class-map.js',
35
+ 'lit-html/directives/style-map.js': '/node_modules/lit-html/directives/style-map.js',
34
36
  '@lit-labs/ssr-client': '/node_modules/@lit-labs/ssr-client/index.js',
35
37
  '@lit-labs/ssr/lib/server-template.js': '/node_modules/@lit-labs/ssr/lib/server-template.js',
36
- mutative: '/node_modules/mutative/dist/mutative.esm.mjs',
37
38
  },
38
39
  };
39
40
  const nodeModulesDir = path.join(rootDir, 'node_modules');
index.d.ts CHANGED
@@ -31,38 +31,18 @@ export type Location = {
31
31
  }
32
32
 
33
33
 
34
+ // Re-exported straight from lit-html: class=${classMap({warning: true})} and
35
+ // style=${styleMap({color: 'red'})}.
34
- export type ReducerActions<P> = {[k: string]: (state: P, v: any) => void};
36
+ export { classMap } from 'lit-html/directives/class-map.js';
35
- export type EffectActions = {[k: string]: (actions: any, v: any) => void};
37
+ export { styleMap } from 'lit-html/directives/style-map.js';
36
38
 
37
- export type ActionsOf<Q> = { [K in keyof Q]: Q[K] extends (state: any, v: infer V) => any ? (v: V) => void : (v: any) => void };
38
- export type EffectsOf<R> = { [K in keyof R]: R[K] extends (actions: any, v: infer V) => any ? (v: V) => void : (v: any) => void };
39
-
40
- export type Reducer<P, Q extends ReducerActions<P>, R extends EffectActions = {}> = {
41
- getValue: () => P;
42
- subscribe: (fn: (v: P) => void) => void;
43
- unsubscribe: (fn: (v: P) => void) => void;
44
- actions: ActionsOf<Q>;
45
- effects: EffectsOf<R>;
46
- };
47
-
48
- export function createReducer<P, Q extends ReducerActions<P>>(props: { initial: P, reducer: Q }): Reducer<P, Q>;
49
-
50
- export function useReducer<P, Q extends ReducerActions<P>, R extends EffectActions = {}>(
51
- props: { initial: P, reducer: Q, effects?: R },
52
- selector?: (state: P) => any,
53
- ): P & { actions: ActionsOf<Q>, effects: EffectsOf<R> };
54
- export function useReducer<P, Q extends ReducerActions<P>, R extends EffectActions = {}>(
55
- reducer: Reducer<P, Q, R>,
56
- selector?: (state: P) => any,
57
- ): P & { actions: ActionsOf<Q>, effects: EffectsOf<R> };
58
-
59
- export type AttrType = 'number' | 'boolean' | 'string';
39
+ export type AttrType = typeof String | typeof Number | typeof Boolean;
60
- // Alternative to calling prop() manually: Fn.attrTypes = { count: 'number' }
40
+ // Alternative to calling prop() manually: Fn.attrs = { count: Number } folds
61
- // folds count into observedAttributes on its own (no need for count to also
41
+ // count into observedAttributes on its own (no need for count to also be a
62
- // be a destructured render-function param), and createElement passes it to
42
+ // destructured render-function param), and createElement passes it to Fn
63
- // Fn already wrapped as a { value } getter/setter box. Reading .value throws
43
+ // already wrapped as a { value } getter/setter box. Reading .value throws if
64
- // if the attribute is missing, or set but not parseable as the declared
44
+ // the attribute is missing, or set but not parseable as the declared
65
- // type — validated against that explicit type rather than sniffed.
45
+ // constructor — validated against that explicit type rather than sniffed.
66
46
  export type AttrTypes = { [key: string]: AttrType };
67
47
  export function createElement(meta: any, renderFn: any): any
68
48
  export function css(strings: TemplateStringsArray, ...values: any[]): string;
@@ -77,13 +57,15 @@ export function staticHtml(strings: TemplateStringsArray, ...values: any[]): any
77
57
  // (e.g. meta=${jsonAttr(data)}) so it round-trips through this library's
78
58
  // attribute-parsing convention instead of being stringified as [object Object].
79
59
  export function jsonAttr(value: any): unknown;
60
+ export type WatchSpec = { selector: string, attribute: string };
61
+ // Alternative to calling a hook manually: Fn.watch = { counts: { selector:
62
+ // 'app-counter', attribute: 'count' } } declares "give me `attribute` off
80
- // Reads `attribute` off every element matching `selector` server-side via
63
+ // every element matching `selector`, wherever it lives on the page" and
64
+ // createElement passes counts to Fn as a plain resolved string[] — computed
81
- // cheerio over the already-rendered preceding markup, client-side via a
65
+ // server-side via cheerio over the already-rendered preceding markup, and
82
- // MutationObserver. Returns the raw attribute strings; parse them yourself.
83
- export function useAttr(selector: string, attribute: string): string[];
84
- // The element instance itself (e.g. to call host.setAttribute(...)). Only
66
+ // client-side via a MutationObserver set up once in connectedCallback and
85
- // call this during render capture the result via closure for later use.
67
+ // torn down on disconnect. No shared store between watcher and watched.
86
- export function useHost(): any;
68
+ export type WatchTypes = { [key: string]: WatchSpec };
87
69
  // State lives only in the named attribute — no separate internal copy, and no
88
70
  // default: the attribute must already be set in markup, or reading `.value`
89
71
  // throws. `name` must also appear as a destructured render-function parameter
index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { html, render as litRender } from 'lit-html';
2
2
  import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
3
+ import { classMap } from 'lit-html/directives/class-map.js';
4
+ import { styleMap } from 'lit-html/directives/style-map.js';
3
5
  import { directive, Directive } from 'lit-html/directive.js';
4
6
  import { hydrate as litHydrate } from '@lit-labs/ssr-client';
5
7
  // A template tagged with staticHtml is never hydrated, so unlike html it can bind
@@ -8,10 +10,9 @@ import { hydrate as litHydrate } from '@lit-labs/ssr-client';
8
10
  // their own tag, e.g. <app-counter>) are unaffected and still hydrate normally —
9
11
  // use it for createPage's head/body, never for a component's own render function.
10
12
  import { html as staticHtml } from '@lit-labs/ssr/lib/server-template.js';
11
- import { create } from 'mutative';
12
13
 
13
14
  const isBrowser = typeof window !== 'undefined';
14
- export { html, isBrowser, unsafeHTML, staticHtml };
15
+ export { html, isBrowser, unsafeHTML, staticHtml, classMap, styleMap };
15
16
 
16
17
  // @lit-labs/ssr and cheerio both pull in Node-only dependencies (module
17
18
  // resolution, fetch polyfills), so they can only ever be imported on the
@@ -23,7 +24,7 @@ const cheerio = isBrowser ? null : await import('cheerio');
23
24
 
24
25
  // Set by expandCustomElements right before rendering each matched component,
25
26
  // 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
+ // (i.e. its preceding siblings, already expanded) — this is what lets watch
27
28
  // answer "what does this other, already-rendered element's attribute say"
28
29
  // during SSR, where there's no live DOM to query.
29
30
  const ssrContext = { precedingHtml: '' };
@@ -97,30 +98,32 @@ const parseTag = (tag) => {
97
98
  };
98
99
  const parseAttrValue = (value) => (value && value.startsWith('{') ? JSON.parse(value.replace(/'/g, '"')) : value);
99
100
 
100
- // Used by createElement's attrTypes handling: unlike prop's sniffed
101
+ // Used by createElement's attrs handling: unlike prop's sniffed
101
- // coercePropValue below, the type here is declared explicitly, so mismatches
102
+ // coercePropValue below, the type here is declared explicitly (as the
103
+ // constructor itself, e.g. Number, matching how Vue's props do it — reads
104
+ // more like a type than a string tag would), so mismatches (not just
102
- // (not just missing attributes) can be caught and reported precisely.
105
+ // missing attributes) can be caught and reported precisely.
103
106
  const coerceTypedProp = (raw, type, key) => {
104
107
  if (raw === undefined) {
105
- throw new Error(`attrTypes.${key}: no "${key}" attribute is set (expected ${type})`);
108
+ throw new Error(`attrs.${key}: no "${key}" attribute is set (expected ${type.name})`);
106
109
  }
107
- if (type === 'number') {
110
+ if (type === Number) {
108
111
  const n = typeof raw === 'number' ? raw : Number(raw);
109
112
  if (Number.isNaN(n)) {
110
- throw new Error(`attrTypes.${key}: "${raw}" is not a valid number`);
113
+ throw new Error(`attrs.${key}: "${raw}" is not a valid number`);
111
114
  }
112
115
  return n;
113
116
  }
114
- if (type === 'boolean') {
117
+ if (type === Boolean) {
115
118
  if (typeof raw === 'boolean') {
116
119
  return raw;
117
120
  }
118
121
  if (raw === 'true' || raw === 'false') {
119
122
  return raw === 'true';
120
123
  }
121
- throw new Error(`attrTypes.${key}: "${raw}" is not a valid boolean (expected "true" or "false")`);
124
+ throw new Error(`attrs.${key}: "${raw}" is not a valid boolean (expected "true" or "false")`);
122
125
  }
123
- if (type === 'string') {
126
+ if (type === String) {
124
127
  return String(raw);
125
128
  }
126
129
  return raw;
@@ -339,128 +342,21 @@ const pageStyles = {
339
342
  };
340
343
 
341
344
  const fifo = (q) => q.shift();
342
- const filo = (q) => q.pop();
343
345
  const microtask = (flush) => () => queueMicrotask(flush);
344
- const task = (flush) => {
345
- if (isBrowser) {
346
- const ch = new window.MessageChannel();
347
- ch.port1.onmessage = flush;
348
- return () => ch.port2.postMessage(null);
349
- } else {
350
- return () => setImmediate(flush);
351
- }
352
- };
353
- const depsChanged = (prev, next) => prev == null || next.some((f, i) => !Object.is(f, prev[i]));
354
346
  const shallowArrayEqual = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
355
347
 
356
348
  export const createAttrs = (attrs) => attrs;
357
349
 
358
- export const createReducer = ({ initial, reducer, effects }) => {
359
- let value = initial;
360
- const subs = new Set();
361
- const actions = Object.keys(reducer).reduce((acc, key) => {
362
- const reduce = reducer[key];
363
- acc[key] = (v) => {
364
- value = create(value, (draft) => reduce(draft, v));
365
- subs.forEach((sub) => {
366
- sub(value);
367
- });
368
- };
369
- return acc;
370
- }, {});
371
- const effectActions = Object.keys(effects || {}).reduce((acc, key) => {
372
- const effect = effects[key];
373
- acc[key] = (v) => effect(actions, v);
374
- return acc;
375
- }, {});
376
- return {
377
- getValue: () => value,
378
- subscribe: (fn) => {
379
- subs.add(fn);
380
- },
381
- unsubscribe: (fn) => {
382
- subs.delete(fn);
383
- },
384
- actions,
385
- effects: effectActions,
386
- };
387
- };
388
-
389
350
  const currentComponent = {
390
351
  current: undefined,
391
352
  set(v) {
392
353
  this.current = v;
393
- this.current.hooks.index = 0;
394
354
  },
395
355
  get() {
396
356
  return this.current;
397
357
  },
398
358
  };
399
359
 
400
- const identity = (v) => v;
401
-
402
- export const useReducer = (reducer, selector = identity) => {
403
- const comp = currentComponent.get();
404
- const index = comp.hooks.index++;
405
- if (!comp.hooks.data[index]) {
406
- comp.hooks.data[index] = reducer.subscribe ? reducer : createReducer(reducer);
407
- comp.hooks.selected[index] = selector(comp.hooks.data[index].getValue());
408
- comp.hooks.data[index].subscribe((value) => {
409
- const next = selector(value);
410
- if (!Object.is(next, comp.hooks.selected[index])) {
411
- comp.hooks.selected[index] = next;
412
- comp.update();
413
- }
414
- });
415
- }
416
- const state = comp.hooks.data[index].getValue();
417
- return { ...state, actions: comp.hooks.data[index].actions, effects: comp.hooks.data[index].effects };
418
- };
419
- // The element instance itself, e.g. to reflect state onto its own attributes
420
- // (host.setAttribute(...)) so other components can read it via useAttr. Safe to
421
- // call only during render — capture the return value via closure for later use
422
- // in effects/handlers, don't call this hook itself from inside one.
423
- export const useHost = () => currentComponent.get();
424
-
425
- export const useEffect = (fn, deps) => {
426
- const comp = currentComponent.get();
427
- const index = comp.hooks.index++;
428
- if (!deps || depsChanged(comp.hooks.deps[index], deps)) {
429
- comp.hooks.deps[index] = deps || [];
430
- comp.hooks.effects[index] = fn;
431
- }
432
- };
433
-
434
- // Reads `attribute` off every element matching `selector`, without either side
435
- // needing a shared store: on the server there's no live DOM to query, so it
436
- // reads from ssrContext.precedingHtml (everything already rendered before this
437
- // component in the current page) via cheerio; in the browser it sets up a
438
- // MutationObserver once and re-renders this component whenever a match changes.
439
- export const useAttr = (selector, attribute) => {
440
- const comp = currentComponent.get();
441
- const index = comp.hooks.index++;
442
- if (!isBrowser) {
443
- const $ = cheerio.load(ssrContext.precedingHtml);
444
- return $(selector)
445
- .map((i, el) => $(el).attr(attribute))
446
- .get();
447
- }
448
- if (!comp.hooks.data[index]) {
449
- const read = () => Array.from(document.querySelectorAll(selector)).map((el) => el.getAttribute(attribute));
450
- comp.hooks.data[index] = read();
451
- const observer = new window.MutationObserver(() => {
452
- const next = read();
453
- if (!shallowArrayEqual(next, comp.hooks.data[index])) {
454
- comp.hooks.data[index] = next;
455
- comp.update();
456
- }
457
- });
458
- observer.observe(document.body, { attributes: true, attributeFilter: [attribute], childList: true, subtree: true });
459
- comp.hooks.cleanups[index] = () => observer.disconnect();
460
- }
461
- return comp.hooks.data[index];
462
- };
463
-
464
360
  // The attribute is the only copy of this state — there's no separate internal
465
361
  // value to keep in sync with it (that bidirectional sync, tried in an earlier
466
362
  // version of this, was a genuine, unresolvable race: whichever side changed
@@ -506,14 +402,30 @@ export const getElement = (name) => registry[name];
506
402
  const BaseElement = isBrowser ? window.HTMLElement : class {};
507
403
  export const createElement = (meta, renderFn) => {
508
404
  const funcParams = parseFuncParams(renderFn);
509
- // Fn.attrTypes = { count: 'number' } is an alternative to calling prop()
405
+ // Fn.attrs = { count: Number } is an alternative to calling prop()
510
406
  // manually: it folds count into observedAttributes on its own (no need for
511
407
  // count to also be a destructured render-function param), and render()
512
408
  // below hands it to renderFn as an already-constructed {value} box, typed
513
- // and validated per the declared type rather than sniffed from the string.
409
+ // and validated per the declared constructor (String/Number/Boolean)
410
+ // rather than sniffed from the string.
514
- const attrTypes = renderFn.attrTypes || {};
411
+ const declaredAttrs = renderFn.attrs || {};
412
+ const declaredAttrKeys = Object.keys(declaredAttrs);
413
+ // Fn.watch = { counts: { selector: 'app-counter', attribute: 'count' } }
414
+ // declares "give me attribute off every element matching selector,
415
+ // wherever it lives on the page" — render() below hands counts to renderFn
416
+ // as a plain resolved array, computed via cheerio over the already-rendered
417
+ // preceding markup on the server, or (below, in connectedCallback) a
418
+ // MutationObserver-backed cache in the browser, re-rendering this component
419
+ // whenever a match changes. No shared store between watcher and watched.
420
+ // counts must be destructured as a render-function param to be usable, but
421
+ // it isn't a real attribute on this element, so it's excluded below rather
422
+ // than leaking into observedAttributes.
423
+ const watch = renderFn.watch || {};
515
- const attrTypeKeys = Object.keys(attrTypes);
424
+ const watchKeys = Object.keys(watch);
425
+ const watchKeySet = new Set(watchKeys.map((k) => k.toLowerCase()));
516
- const observedAttrs = Array.from(new Set([...funcParams, ...attrTypeKeys])).map((k) => k.toLowerCase());
426
+ const observedAttrs = Array.from(new Set([...funcParams, ...declaredAttrKeys]))
427
+ .map((k) => k.toLowerCase())
428
+ .filter((k) => !watchKeySet.has(k));
517
429
  const RenderElement = class extends BaseElement {
518
430
  static get observedAttributes() {
519
431
  return observedAttrs;
@@ -526,33 +438,40 @@ export const createElement = (meta, renderFn) => {
526
438
  constructor(ssrAttrs) {
527
439
  super();
528
440
  this._dirty = false;
529
- this._effectsPending = false;
530
441
  this._connected = false;
531
442
  this._hydrated = false;
532
443
  this.attrs = ssrAttrs || {};
533
- this.state = {};
444
+ this._watchData = {};
534
- this.hooks = {
445
+ this._watchCleanups = {};
535
- index: 0,
536
- data: {},
537
- deps: {},
538
- effects: {},
539
- cleanups: {},
540
- selected: {},
541
- };
542
446
  this.renderFn = renderFn;
543
447
  }
544
448
 
545
449
  connectedCallback() {
546
450
  this._connected = true;
451
+ if (isBrowser) {
452
+ for (const key of watchKeys) {
453
+ const { selector, attribute } = watch[key];
454
+ const read = () => Array.from(document.querySelectorAll(selector)).map((el) => el.getAttribute(attribute));
455
+ this._watchData[key] = read();
456
+ const observer = new window.MutationObserver(() => {
457
+ const next = read();
458
+ if (!shallowArrayEqual(next, this._watchData[key])) {
459
+ this._watchData[key] = next;
460
+ this.update();
461
+ }
462
+ });
463
+ observer.observe(document.body, { attributes: true, attributeFilter: [attribute], childList: true, subtree: true });
464
+ this._watchCleanups[key] = () => observer.disconnect();
465
+ }
466
+ }
547
467
  this.update();
548
468
  }
549
469
 
550
470
  disconnectedCallback() {
551
471
  this._connected = false;
552
- const cleanups = this.hooks.cleanups;
553
- Object.keys(cleanups).forEach((key) => {
472
+ Object.keys(this._watchCleanups).forEach((key) => {
554
- cleanups[key] && cleanups[key]();
473
+ this._watchCleanups[key]();
555
- delete cleanups[key];
474
+ delete this._watchCleanups[key];
556
475
  });
557
476
  }
558
477
 
@@ -576,26 +495,9 @@ export const createElement = (meta, renderFn) => {
576
495
  return;
577
496
  }
578
497
  this.render();
579
- this.enqueueEffects();
580
498
  this._dirty = false;
581
499
  }
582
500
 
583
- _flushEffects() {
584
- const effects = this.hooks.effects;
585
- const cleanups = this.hooks.cleanups;
586
- const keys = Object.keys(effects);
587
- for (const key of keys) {
588
- if (effects[key]) {
589
- cleanups[key] && cleanups[key]();
590
- const cleanup = effects[key]();
591
- if (cleanup) {
592
- cleanups[key] = cleanup;
593
- }
594
- delete effects[key];
595
- }
596
- }
597
- }
598
-
599
501
  batch(runner, pick, callback) {
600
502
  const q = [];
601
503
  const flush = () => {
@@ -610,23 +512,6 @@ export const createElement = (meta, renderFn) => {
610
512
  this.batch(microtask, fifo, () => this._performUpdate());
611
513
  }
612
514
 
613
- enqueueEffects() {
614
- // Without this guard, several render cycles completing in quick succession
615
- // (e.g. rapid clicks) each schedule their own separate effects-flush task;
616
- // those can interleave with later renders' own effect scheduling in ways
617
- // that break invariants effects rely on (observed this firsthand: a
618
- // still-pending flush from an earlier render raced a newer one and
619
- // clobbered state that had already moved on).
620
- if (this._effectsPending) {
621
- return;
622
- }
623
- this._effectsPending = true;
624
- this.batch(task, filo, () => {
625
- this._effectsPending = false;
626
- this._flushEffects();
627
- });
628
- }
629
-
630
515
  render() {
631
516
  currentComponent.set(this);
632
517
  const comp = this;
@@ -635,16 +520,27 @@ export const createElement = (meta, renderFn) => {
635
520
  config: isBrowser ? window.props.config : global?.props?.config,
636
521
  location: isBrowser ? window.location : global?.location,
637
522
  };
638
- for (const key of attrTypeKeys) {
523
+ for (const key of declaredAttrKeys) {
639
524
  props[key] = {
640
525
  get value() {
641
- return coerceTypedProp(comp.attrs[key], attrTypes[key], key);
526
+ return coerceTypedProp(comp.attrs[key], declaredAttrs[key], key);
642
527
  },
643
528
  set value(next) {
644
529
  comp.setAttribute(key, serializeAttr(next));
645
530
  },
646
531
  };
647
532
  }
533
+ for (const key of watchKeys) {
534
+ const { selector, attribute } = watch[key];
535
+ if (!isBrowser) {
536
+ const $ = cheerio.load(ssrContext.precedingHtml);
537
+ props[key] = $(selector)
538
+ .map((i, el) => $(el).attr(attribute))
539
+ .get();
540
+ } else {
541
+ props[key] = comp._watchData[key];
542
+ }
543
+ }
648
544
  const template = this.renderFn(props);
649
545
  if (isBrowser) {
650
546
  const styles = RenderElement.styles;
index.test.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { getElement, createElement, createPage, createReducer, html, staticHtml, renderHtml, unsafeHTML, css, useReducer, useEffect, jsonAttr } from './index.js';
3
+ import { getElement, createElement, createPage, html, staticHtml, renderHtml, unsafeHTML, css, jsonAttr } from './index.js';
4
4
 
5
5
  test('css tagged template', (t) => {
6
6
  const color = 'magenta';
@@ -109,81 +109,22 @@ test('render multi template', (t) => {
109
109
  t.assert.snapshot(res);
110
110
  });
111
111
 
112
- test('createReducer', (t) => {
113
- const countReducer = createReducer({
114
- initial: {
115
- count: 0,
116
- other: { untouched: true },
117
- },
118
- reducer: {
119
- increment: (state, a) => {
120
- state.count += a;
121
- },
122
- decrement: (state, a) => {
123
- state.count -= a;
124
- },
125
- },
126
- });
127
- const mock = t.mock.fn();
128
- countReducer.subscribe(mock);
129
- const before = countReducer.getValue();
130
- countReducer.actions.increment(4);
131
- assert.strictEqual(countReducer.getValue().count, 4);
132
- assert.deepStrictEqual(mock.mock.calls[0].arguments, [{ count: 4, other: { untouched: true } }]);
133
- countReducer.actions.decrement(1);
134
- assert.strictEqual(countReducer.getValue().count, 3);
135
- assert.deepStrictEqual(mock.mock.calls[1].arguments, [{ count: 3, other: { untouched: true } }]);
136
- countReducer.actions.decrement(2);
137
- const after = countReducer.getValue();
138
- assert.strictEqual(after.count, 1);
139
- assert.deepStrictEqual(mock.mock.calls[2].arguments, [{ count: 1, other: { untouched: true } }]);
140
- assert.notStrictEqual(after, before);
141
- assert.strictEqual(after.other, before.other);
142
- });
143
-
144
- test('useReducer selector only updates when the selected slice changes', (t) => {
112
+ test('watch resolves attribute values from preceding SSR markup via cheerio', (t) => {
113
+ const Source = ({ value }) => html`<span>${value.value}</span>`;
145
- const store = createReducer({
114
+ Source.attrs = { value: Number };
146
- initial: { count: 0, other: 0 },
147
- reducer: {
148
- incCount: (state) => {
149
- state.count += 1;
150
- },
151
- incOther: (state) => {
152
- state.other += 1;
153
- },
154
- },
155
- });
156
- createElement({ url: '/count-only.js' }, () => {
115
+ createElement({ url: '/watch-source.js' }, Source);
157
- useReducer(store, (state) => state.count);
158
- return html` <div></div> `;
159
- });
160
- const Clazz = getElement('count-only');
161
- const instance = new Clazz();
162
- instance.render();
163
- const updateSpy = t.mock.fn();
164
- instance.update = updateSpy;
165
116
 
117
+ const Watcher = ({ values }) => html`<div>${values.join(',')}</div>`;
166
- store.actions.incOther();
118
+ Watcher.watch = { values: { selector: 'watch-source', attribute: 'value' } };
167
- assert.strictEqual(updateSpy.mock.callCount(), 0);
119
+ createElement({ url: '/watch-total.js' }, Watcher);
168
120
 
169
- store.actions.incCount();
170
- assert.strictEqual(updateSpy.mock.callCount(), 1);
171
- });
172
-
173
- test('useEffect cleanup runs on disconnect', (t) => {
174
- const cleanup = t.mock.fn();
121
+ const template = html`
175
- createElement({ url: '/cleanup-element.js' }, () => {
122
+ <watch-source value="3"></watch-source>
176
- useEffect(() => cleanup, []);
123
+ <watch-source value="4"></watch-source>
177
- return html` <div></div> `;
124
+ <watch-total></watch-total>
178
- });
125
+ `;
179
- const Clazz = getElement('cleanup-element');
180
- const instance = new Clazz();
126
+ const res = renderHtml(template);
181
- instance.render();
182
- instance._flushEffects();
127
+ t.assert.snapshot(res);
183
- assert.strictEqual(cleanup.mock.callCount(), 0);
184
-
185
- instance.disconnectedCallback();
186
- assert.strictEqual(cleanup.mock.callCount(), 1);
187
128
  });
188
129
 
189
130
  test('createElement without attrs', (t) => {
@@ -196,34 +137,25 @@ test('createElement without attrs', (t) => {
196
137
  t.assert.snapshot(res);
197
138
  });
198
139
 
199
- test('createElement with attrs and hooks', (t) => {
140
+ test('createElement with plain and declared attrs', (t) => {
200
- createElement({ url: '/base-element.js' }, ({ perPage }) => {
201
- const { count, actions } = useReducer({
141
+ const Base = ({ perPage, count }) => {
202
- initial: {
203
- count: 3,
204
- },
205
- reducer: {
206
- setValue: (state, v) => {
142
+ const increment = () => {
207
- state.count = v;
143
+ count.value += 1;
208
- },
209
- },
210
- });
144
+ };
211
145
  return html`
212
146
  <div>
213
- <div>
214
- <span>perPage: ${perPage}</span>
147
+ <span>perPage: ${perPage}</span>
215
- </div>
216
- </div>
217
- <span>Count: ${count}</span>
148
+ <span>Count: ${count.value}</span>
218
- </div>
219
- <button @click=${actions.setValue}>Set</button>
149
+ <button @click=${increment}>Set</button>
220
150
  </div>
221
151
  `;
222
- });
152
+ };
153
+ Base.attrs = { count: Number };
154
+ createElement({ url: '/base-element.js' }, Base);
223
155
  const Clazz = getElement('base-element');
224
- const instance = new Clazz({ perPage: 5 });
156
+ const instance = new Clazz({ perPage: 5, count: 3 });
225
157
  const res = instance.render();
226
- assert.deepStrictEqual(Clazz.observedAttributes, ['perpage']);
158
+ assert.deepStrictEqual(Clazz.observedAttributes, ['perpage', 'count']);
227
159
  t.assert.snapshot(res);
228
160
  });
229
161
 
index.test.js.snapshot CHANGED
@@ -2,8 +2,8 @@ exports[`createElement styles are scoped and injected via SSR 1`] = `
2
2
  "@scope (styled-element) {\\n\\n :scope {\\n display: flex;\\n }\\n .count {\\n color: magenta;\\n }\\n \\n}\\n"
3
3
  `;
4
4
 
5
- exports[`createElement with attrs and hooks 1`] = `
6
- "<!--lit-part z5rbBgZAlEw=-->\\n <div>\\n <div>\\n <span>perPage: <!--lit-part-->5<!--/lit-part--></span>\\n </div>\\n </div>\\n <span>Count: <!--lit-part-->3<!--/lit-part--></span>\\n </div>\\n <!--lit-node 6--><button >Set</button>\\n </div>\\n <!--/lit-part-->"
5
+ exports[`createElement with plain and declared attrs 1`] = `
6
+ "<!--lit-part Nrj3ntAoYvc=-->\\n <div>\\n <span>perPage: <!--lit-part-->5<!--/lit-part--></span>\\n <span>Count: <!--lit-part-->3<!--/lit-part--></span>\\n <!--lit-node 5--><button >Set</button>\\n </div>\\n <!--/lit-part-->"
7
7
  `;
8
8
 
9
9
  exports[`createElement without attrs 1`] = `
@@ -45,3 +45,7 @@ exports[`renderHtml 1`] = `
45
45
  exports[`renderHtml escapes text and attribute values 1`] = `
46
46
  "<!--lit-part PkF/hiJU4II=--><!--lit-node 0--><div class=\\"a&quot; onmouseover=&quot;alert(1)\\"><!--lit-part-->&lt;script&gt;alert(1)&lt;/script&gt;<!--/lit-part--></div><!--/lit-part-->"
47
47
  `;
48
+
49
+ exports[`watch resolves attribute values from preceding SSR markup via cheerio 1`] = `
50
+ "<!--lit-part vohwtdYoJ0A=-->\\n <watch-source value=\\"3\\"><!--lit-part 94tjhjhkEYE=--><span><!--lit-part-->3<!--/lit-part--></span><!--/lit-part--></watch-source>\\n <watch-source value=\\"4\\"><!--lit-part 94tjhjhkEYE=--><span><!--lit-part-->4<!--/lit-part--></span><!--/lit-part--></watch-source>\\n <watch-total><!--lit-part AEmR7W+R0Ak=--><div><!--lit-part-->3,4<!--/lit-part--></div><!--/lit-part--></watch-total>\\n <!--/lit-part-->"
51
+ `;
package-lock.json CHANGED
@@ -12,8 +12,7 @@
12
12
  "@lit-labs/ssr": "^4.1.0",
13
13
  "@lit-labs/ssr-client": "^1.1.8",
14
14
  "cheerio": "^1.2.0",
15
- "lit-html": "^3.3.3",
15
+ "lit-html": "^3.3.3"
16
- "mutative": "^1.3.0"
17
16
  },
18
17
  "devDependencies": {
19
18
  "playwright": "^1.62.1"
@@ -397,15 +396,6 @@
397
396
  "@types/trusted-types": "^2.0.2"
398
397
  }
399
398
  },
400
- "node_modules/mutative": {
401
- "version": "1.3.0",
402
- "resolved": "https://registry.npmjs.org/mutative/-/mutative-1.3.0.tgz",
403
- "integrity": "sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ==",
404
- "license": "MIT",
405
- "engines": {
406
- "node": ">=14.0"
407
- }
408
- },
409
399
  "node_modules/node-domexception": {
410
400
  "version": "1.0.0",
411
401
  "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -851,11 +841,6 @@
851
841
  "@types/trusted-types": "^2.0.2"
852
842
  }
853
843
  },
854
- "mutative": {
855
- "version": "1.3.0",
856
- "resolved": "https://registry.npmjs.org/mutative/-/mutative-1.3.0.tgz",
857
- "integrity": "sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ=="
858
- },
859
844
  "node-domexception": {
860
845
  "version": "1.0.0",
861
846
  "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atoms-element",
3
- "version": "3.0.1",
3
+ "version": "5.0.0",
4
4
  "description": "A simple web component library for defining your custom elements. It works on both client and server. It supports hooks and follows the same principles of react.",
5
5
  "keywords": [
6
6
  "pyrossh",
@@ -42,7 +42,6 @@
42
42
  "@lit-labs/ssr": "^4.1.0",
43
43
  "@lit-labs/ssr-client": "^1.1.8",
44
44
  "cheerio": "^1.2.0",
45
- "lit-html": "^3.3.3",
45
+ "lit-html": "^3.3.3"
46
- "mutative": "^1.3.0"
47
46
  }
48
47
  }
readme.md CHANGED
@@ -19,43 +19,37 @@ After going through all these libraries,
19
19
  5. [Atomico](https://github.com/atomicojs/atomico)
20
20
  6. [fuco](https://github.com/wtnbass/fuco)
21
21
 
22
- And figuring out how each one implemented their on custom elements I came up with atoms-element. It still doesn't have proper rehydration lit-html just replaces the DOM under the server rendered web component for now. Atomico has implemented proper SSR with hydration so I might need to look into that in the future or
23
- use it instead of lit-html. Since I made a few modifications like json attributes and attrTypes I don't know if it will easy.
22
+ And figuring out how each one implemented their on custom elements I came up with atoms-element. It now does proper rehydration: the server renders with [@lit-labs/ssr](https://github.com/lit/lit/tree/main/packages/labs/ssr), which emits hydration marker comments into the light DOM, and the client attaches to that same DOM via [@lit-labs/ssr-client](https://github.com/lit/lit/tree/main/packages/labs/ssr-client)'s `hydrate()` instead of tearing it down and re-rendering from scratch so things like focus survive the first client render.
23
+
24
+ State lives directly in attributes rather than a separate reducer/store: `prop(name)` reads/writes a single attribute as a live `{ value }` getter/setter, and `Fn.attrs = { count: Number }` goes further, declaring a set of typed, required attributes up front (using the constructor itself as the type, the way Vue's props do) so they don't need to be called out individually and so mistyped or missing values throw immediately instead of failing silently.
24
25
 
25
26
  ## Example
26
27
 
27
28
  ```js
28
- import { createElement, useReducer, html, renderHtml } from 'atoms-element/index.js';
29
+ import { createElement, html, renderHtml } from 'atoms-element/index.js';
29
-
30
+
30
- const Counter = ({ name, meta }) => {
31
+ const Counter = ({ name, count }) => {
31
- const { count, actions } = useReducer({
32
- initial: {
33
- count: 0,
34
- },
35
- reducer: {
36
- increment: (state) => ({ ...state, count: state.count + 1 }),
32
+ const increment = () => { count.value += 1; };
37
- decrement: (state) => ({ ...state, count: state.count - 1 }),
33
+ const decrement = () => { count.value -= 1; };
38
- },
39
- });
40
- const warningClass = count > 10 ? 'text-red-500' : '';
34
+ const warningClass = count.value > 10 ? 'text-red-500' : '';
41
35
  return html`
42
36
  <div class="mt-10">
43
37
  <div class="mb-2">
44
38
  Counter: ${name}
45
- <span>starts at ${meta?.start}</span>
46
39
  </div>
47
40
  <div class="flex flex-1 flex-row items-center text-gray-700">
48
- <button class="bg-gray-300 text-gray-700 rounded hover:bg-gray-200 px-4 py-2 text-3xl focus:outline-none" @click=${actions.decrement}>-</button>
41
+ <button class="bg-gray-300 text-gray-700 rounded hover:bg-gray-200 px-4 py-2 text-3xl focus:outline-none" @click=${decrement}>-</button>
49
42
  <div class="mx-20">
50
- <h1 class="text-3xl font-mono ${warningClass}">${count}</h1>
43
+ <h1 class="text-3xl font-mono ${warningClass}">${count.value}</h1>
51
44
  </div>
52
- <button class="bg-gray-300 text-gray-700 rounded hover:bg-gray-200 px-4 py-2 text-3xl focus:outline-none" @click=${actions.increment}>+</button>
45
+ <button class="bg-gray-300 text-gray-700 rounded hover:bg-gray-200 px-4 py-2 text-3xl focus:outline-none" @click=${increment}>+</button>
53
46
  </div>
54
47
  </div>
55
48
  `;
56
49
  };
50
+ Counter.attrs = { count: Number };
57
51
 
58
52
  createElement({ url: 'app-counter.js' }, Counter);
59
53
 
60
- console.log(renderHtml(html`<app-counter name="1"></app-counter>`));
54
+ console.log(renderHtml(html`<app-counter name="1" count="0"></app-counter>`));
61
55
  ```