atoms-element v5.0.0
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.
e955350
— Peter John
2026-08-07T19:46:46+05:30
release v5
- .npmrc +1 -0
- examples/e2e.spec.js +17 -15
- examples/elements/app-counter.js +28 -29
- examples/elements/app-total.js +9 -6
- examples/server.js +8 -7
- index.d.ts +20 -38
- index.js +70 -174
- index.test.js +28 -96
- index.test.js.snapshot +6 -2
- package-lock.json +1 -16
- package.json +2 -3
- readme.md +14 -20
.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('
|
|
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('
|
|
55
|
+
assert.strictEqual((await first.locator('output').textContent()).trim(), '5', 'first app-counter has count="5"');
|
|
56
|
-
assert.strictEqual((await second.locator('
|
|
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
|
|
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: '
|
|
69
|
+
await first.getByRole('button', { name: 'Increment' }).click();
|
|
68
|
-
await first.getByRole('button', { name: '
|
|
70
|
+
await first.getByRole('button', { name: 'Increment' }).click();
|
|
69
|
-
await first.getByRole('button', { name: '
|
|
71
|
+
await first.getByRole('button', { name: 'Increment' }).click();
|
|
70
|
-
await second.getByRole('button', { name: '
|
|
72
|
+
await second.getByRole('button', { name: 'Increment' }).click();
|
|
71
|
-
await first.getByRole('button', { name: '
|
|
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('
|
|
79
|
+
assert.strictEqual((await first.locator('output').textContent()).trim(), '7');
|
|
78
|
-
assert.strictEqual((await second.locator('
|
|
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
|
|
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('
|
|
91
|
+
await page.waitForFunction(() => document.querySelector('app-counter').querySelector('output').textContent.trim() === '30');
|
|
90
|
-
assert.strictEqual((await first.locator('
|
|
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.
|
|
4
|
+
// keep in sync. Counter.attrs below declares count as a Number: that folds
|
|
5
|
-
//
|
|
5
|
+
// it into observedAttributes on its own (name is still destructured as a
|
|
6
|
-
//
|
|
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
|
|
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
|
-
|
|
22
|
+
<p>Counter: ${name}</p>
|
|
20
|
-
|
|
23
|
+
<div class="controls">
|
|
21
|
-
|
|
24
|
+
<button type="button" aria-label="Decrement" @click=${decrement}>-</button>
|
|
22
|
-
<div class="count">
|
|
23
|
-
|
|
25
|
+
<output class=${classMap({ warning: count.value > 10 })}>${count.value}</output>
|
|
24
|
-
</div>
|
|
25
|
-
|
|
26
|
+
<button type="button" aria-label="Increment" @click=${increment}>+</button>
|
|
26
|
-
</div>
|
|
27
27
|
</div>
|
|
28
28
|
`;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
Counter.
|
|
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
|
-
|
|
42
|
+
p {
|
|
42
|
-
margin
|
|
43
|
+
margin: 0 0 0.5rem;
|
|
43
44
|
}
|
|
44
45
|
.controls {
|
|
45
46
|
display: flex;
|
|
46
47
|
align-items: center;
|
|
47
48
|
}
|
|
48
|
-
|
|
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
|
-
.
|
|
54
|
+
output.warning {
|
|
56
|
-
color:
|
|
55
|
+
color: var(--color-danger);
|
|
57
56
|
}
|
|
58
57
|
button {
|
|
59
|
-
background-color:
|
|
58
|
+
background-color: var(--color-button-bg);
|
|
60
|
-
color:
|
|
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:
|
|
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
|
|
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
|
|
20
|
+
// lit-html and @lit-labs/ssr-client are real npm dependencies (not vendored),
|
|
21
|
-
//
|
|
21
|
+
// so the browser needs an import map for every bare specifier reachable from
|
|
22
|
-
//
|
|
22
|
+
// index.js — including ones used by their own internal files, since the
|
|
23
|
-
//
|
|
23
|
+
// import map is global to the page, not scoped per-package the way Node's
|
|
24
|
-
//
|
|
24
|
+
// node_modules resolution is. The packages' files are served as-is; their
|
|
25
|
-
//
|
|
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
|
|
36
|
+
export { classMap } from 'lit-html/directives/class-map.js';
|
|
35
|
-
export
|
|
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 =
|
|
39
|
+
export type AttrType = typeof String | typeof Number | typeof Boolean;
|
|
60
|
-
// Alternative to calling prop() manually: Fn.
|
|
40
|
+
// Alternative to calling prop() manually: Fn.attrs = { count: Number } folds
|
|
61
|
-
//
|
|
41
|
+
// count into observedAttributes on its own (no need for count to also be a
|
|
62
|
-
//
|
|
42
|
+
// destructured render-function param), and createElement passes it to Fn
|
|
63
|
-
//
|
|
43
|
+
// already wrapped as a { value } getter/setter box. Reading .value throws if
|
|
64
|
-
//
|
|
44
|
+
// the attribute is missing, or set but not parseable as the declared
|
|
65
|
-
//
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
66
|
+
// client-side via a MutationObserver set up once in connectedCallback and
|
|
85
|
-
//
|
|
67
|
+
// torn down on disconnect. No shared store between watcher and watched.
|
|
86
|
-
export
|
|
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
|
|
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
|
|
101
|
+
// Used by createElement's attrs handling: unlike prop's sniffed
|
|
101
|
-
// coercePropValue below, the type here is declared explicitly
|
|
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
|
-
//
|
|
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(`
|
|
108
|
+
throw new Error(`attrs.${key}: no "${key}" attribute is set (expected ${type.name})`);
|
|
106
109
|
}
|
|
107
|
-
if (type ===
|
|
110
|
+
if (type === Number) {
|
|
108
111
|
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
109
112
|
if (Number.isNaN(n)) {
|
|
110
|
-
throw new Error(`
|
|
113
|
+
throw new Error(`attrs.${key}: "${raw}" is not a valid number`);
|
|
111
114
|
}
|
|
112
115
|
return n;
|
|
113
116
|
}
|
|
114
|
-
if (type ===
|
|
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(`
|
|
124
|
+
throw new Error(`attrs.${key}: "${raw}" is not a valid boolean (expected "true" or "false")`);
|
|
122
125
|
}
|
|
123
|
-
if (type ===
|
|
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.
|
|
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
|
|
409
|
+
// and validated per the declared constructor (String/Number/Boolean)
|
|
410
|
+
// rather than sniffed from the string.
|
|
514
|
-
const
|
|
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
|
|
424
|
+
const watchKeys = Object.keys(watch);
|
|
425
|
+
const watchKeySet = new Set(watchKeys.map((k) => k.toLowerCase()));
|
|
516
|
-
const observedAttrs = Array.from(new Set([...funcParams, ...
|
|
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.
|
|
444
|
+
this._watchData = {};
|
|
534
|
-
this.
|
|
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(
|
|
472
|
+
Object.keys(this._watchCleanups).forEach((key) => {
|
|
554
|
-
|
|
473
|
+
this._watchCleanups[key]();
|
|
555
|
-
delete
|
|
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
|
|
523
|
+
for (const key of declaredAttrKeys) {
|
|
639
524
|
props[key] = {
|
|
640
525
|
get value() {
|
|
641
|
-
return coerceTypedProp(comp.attrs[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,
|
|
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('
|
|
112
|
+
test('watch resolves attribute values from preceding SSR markup via cheerio', (t) => {
|
|
113
|
+
const Source = ({ value }) => html`<span>${value.value}</span>`;
|
|
145
|
-
|
|
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: '/
|
|
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
|
-
|
|
118
|
+
Watcher.watch = { values: { selector: 'watch-source', attribute: 'value' } };
|
|
167
|
-
|
|
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
|
|
121
|
+
const template = html`
|
|
175
|
-
|
|
122
|
+
<watch-source value="3"></watch-source>
|
|
176
|
-
|
|
123
|
+
<watch-source value="4"></watch-source>
|
|
177
|
-
|
|
124
|
+
<watch-total></watch-total>
|
|
178
|
-
|
|
125
|
+
`;
|
|
179
|
-
const Clazz = getElement('cleanup-element');
|
|
180
|
-
const
|
|
126
|
+
const res = renderHtml(template);
|
|
181
|
-
instance.render();
|
|
182
|
-
|
|
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
|
|
140
|
+
test('createElement with plain and declared attrs', (t) => {
|
|
200
|
-
createElement({ url: '/base-element.js' }, ({ perPage }) => {
|
|
201
|
-
|
|
141
|
+
const Base = ({ perPage, count }) => {
|
|
202
|
-
initial: {
|
|
203
|
-
count: 3,
|
|
204
|
-
},
|
|
205
|
-
reducer: {
|
|
206
|
-
|
|
142
|
+
const increment = () => {
|
|
207
|
-
|
|
143
|
+
count.value += 1;
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
}
|
|
144
|
+
};
|
|
211
145
|
return html`
|
|
212
146
|
<div>
|
|
213
|
-
<div>
|
|
214
|
-
|
|
147
|
+
<span>perPage: ${perPage}</span>
|
|
215
|
-
</div>
|
|
216
|
-
</div>
|
|
217
|
-
|
|
148
|
+
<span>Count: ${count.value}</span>
|
|
218
|
-
</div>
|
|
219
|
-
<button @click=${
|
|
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
|
|
6
|
-
"<!--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" onmouseover="alert(1)\\"><!--lit-part--><script>alert(1)</script><!--/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
|
+
"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
|
|
23
|
-
|
|
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,
|
|
29
|
+
import { createElement, html, renderHtml } from 'atoms-element/index.js';
|
|
29
|
-
|
|
30
|
+
|
|
30
|
-
const Counter = ({ name,
|
|
31
|
+
const Counter = ({ name, count }) => {
|
|
31
|
-
const { count, actions } = useReducer({
|
|
32
|
-
initial: {
|
|
33
|
-
count: 0,
|
|
34
|
-
},
|
|
35
|
-
reducer: {
|
|
36
|
-
|
|
32
|
+
const increment = () => { count.value += 1; };
|
|
37
|
-
|
|
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=${
|
|
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=${
|
|
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
|
```
|