@webjsdev/core 0.7.65 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +19 -19
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +6 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +5 -1
- package/index.d.ts +5 -1
- package/index.js +5 -1
- package/package.json +1 -1
- package/src/attribute-directives.d.ts +6 -0
- package/src/attribute-directives.js +49 -0
- package/src/attribute-reader.js +17 -3
- package/src/binding-prefixes.js +10 -41
- package/src/component/lifecycle.js +177 -59
- package/src/component/reactive.js +5 -4
- package/src/component.d.ts +11 -5
- package/src/directives.d.ts +2 -0
- package/src/directives.js +2 -0
- package/src/html.d.ts +4 -0
- package/src/html.js +22 -1
- package/src/render-client/adopt-slots.js +245 -0
- package/src/render-client/parts.js +51 -17
- package/src/render-client/reconciler.js +28 -12
- package/src/render-client/template-compiler.js +19 -5
- package/src/render-client.d.ts +2 -1
- package/src/render-server/compile.js +16 -2
- package/src/render-server/expand.js +39 -12
- package/src/render-server/served-attrs.js +79 -0
- package/src/render-server/walk.js +42 -2
- package/src/router-client/anchors.js +3 -1
- package/src/router-client/boundaries.js +15 -1
- package/src/router-client/constants.js +30 -0
- package/src/router-client/fetch-apply.js +21 -0
- package/src/router-client/frames.js +6 -3
- package/src/router-client/head-merge.js +58 -10
- package/src/router-client/navigator.js +4 -0
- package/src/router-client/owned-styles.js +372 -0
- package/src/router-client/prefetch.js +3 -0
- package/src/router-client/stylesheets.js +191 -0
- package/src/router-client/swap.js +49 -9
- package/src/sentinels.d.ts +2 -0
- package/src/sentinels.js +4 -0
- package/src/webjs-config.d.ts +1 -1
package/index-browser.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* Keep this list in sync with `./index.js` for everything else.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
export { html, isTemplate, MARKER } from './src/html.js';
|
|
26
|
+
export { html, svg, isTemplate, MARKER } from './src/html.js';
|
|
27
27
|
export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
|
|
28
28
|
export { WebComponent, prop } from './src/component.js';
|
|
29
29
|
export { lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js';
|
|
@@ -100,3 +100,7 @@ export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent }
|
|
|
100
100
|
|
|
101
101
|
// Task controller, also available via '@webjsdev/core/task'
|
|
102
102
|
export { Task, TaskStatus } from './src/task.js';
|
|
103
|
+
|
|
104
|
+
export { nothing, noChange } from './src/sentinels.js';
|
|
105
|
+
|
|
106
|
+
export { classMap, styleMap, ifDefined } from './src/attribute-directives.js';
|
package/index.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export type {
|
|
|
74
74
|
NonSerializable,
|
|
75
75
|
} from './src/serializable.d.ts';
|
|
76
76
|
|
|
77
|
-
export { html, isTemplate, MARKER } from './src/html.js';
|
|
77
|
+
export { html, svg, isTemplate, MARKER } from './src/html.js';
|
|
78
78
|
// The `html\`\`` return type. A JSDoc typedef in html.js used across the
|
|
79
79
|
// component / route / testing type surfaces; re-exported so an app or the
|
|
80
80
|
// docs/website can annotate a value as `TemplateResult` (#772).
|
|
@@ -159,3 +159,7 @@ export function optimistic<T, R>(
|
|
|
159
159
|
action: () => Promise<R> | R,
|
|
160
160
|
): Promise<R>;
|
|
161
161
|
|
|
162
|
+
|
|
163
|
+
export { nothing, noChange } from './src/sentinels.js';
|
|
164
|
+
|
|
165
|
+
export { classMap, styleMap, ifDefined } from './src/attribute-directives.js';
|
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* renderer is only reached on the server.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
export { html, isTemplate, MARKER } from './src/html.js';
|
|
9
|
+
export { html, svg, isTemplate, MARKER } from './src/html.js';
|
|
10
10
|
export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
|
|
11
11
|
export { WebComponent, prop } from './src/component.js';
|
|
12
12
|
export { lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js';
|
|
@@ -68,3 +68,7 @@ export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent }
|
|
|
68
68
|
|
|
69
69
|
// Task controller, also available via '@webjsdev/core/task'
|
|
70
70
|
export { Task, TaskStatus } from './src/task.js';
|
|
71
|
+
|
|
72
|
+
export { nothing, noChange } from './src/sentinels.js';
|
|
73
|
+
|
|
74
|
+
export { classMap, styleMap, ifDefined } from './src/attribute-directives.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The runtime for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Ships the html and css template tags, the WebComponent base class, signals, directives, and the isomorphic renderers.",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { nothing } from './sentinels.js';
|
|
2
|
+
export interface ClassMapDirective { readonly _$webjs: 'class-map'; readonly value: Record<string, unknown>; }
|
|
3
|
+
export interface StyleMapDirective { readonly _$webjs: 'style-map'; readonly value: Record<string, string | number | null | undefined>; }
|
|
4
|
+
export function classMap(classes: Record<string, unknown>): ClassMapDirective;
|
|
5
|
+
export function styleMap(styles: Record<string, string | number | null | undefined>): StyleMapDirective;
|
|
6
|
+
export function ifDefined<T>(value: T): NonNullable<T> | typeof nothing;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { nothing } from './sentinels.js';
|
|
2
|
+
|
|
3
|
+
/** Map class names to whether each should be present. @param {Record<string, unknown>} classes */
|
|
4
|
+
export function classMap(classes) { return { _$webjs: 'class-map', value: classes }; }
|
|
5
|
+
/** Map CSS property names to their values. @param {Record<string, string | number | null | undefined>} styles */
|
|
6
|
+
export function styleMap(styles) { return { _$webjs: 'style-map', value: styles }; }
|
|
7
|
+
/** Remove an attribute when its value is nullish. @template T @param {T} value */
|
|
8
|
+
export function ifDefined(value) { return value ?? nothing; }
|
|
9
|
+
|
|
10
|
+
export function isAttributeMap(value) {
|
|
11
|
+
return value && (value._$webjs === 'class-map' || value._$webjs === 'style-map');
|
|
12
|
+
}
|
|
13
|
+
export function cssName(name) { return name.startsWith('--') ? name : name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); }
|
|
14
|
+
export function attributeMapText(value, name) {
|
|
15
|
+
const classes = value._$webjs === 'class-map';
|
|
16
|
+
if (name !== (classes ? 'class' : 'style')) throw new Error('classMap/styleMap must be used in a class/style attribute.');
|
|
17
|
+
// Lit deliberately pads even an empty map to separate adjacent static classes.
|
|
18
|
+
return classes
|
|
19
|
+
? ' ' + Object.keys(value.value).filter((key) => value.value[key]).join(' ') + ' '
|
|
20
|
+
: Object.entries(value.value).filter(([, v]) => v != null).map(([key, v]) => `${cssName(key)}:${v};`).join('');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Commit only the map-owned tokens, preserving static and externally added values. */
|
|
24
|
+
export function applyAttributeMap(part, value) {
|
|
25
|
+
if (part.kind !== 'attr' && part.kind !== 'attr-mixed') throw new Error('classMap/styleMap require an attribute binding.');
|
|
26
|
+
const classes = value._$webjs === 'class-map';
|
|
27
|
+
if (part.name !== (classes ? 'class' : 'style')) throw new Error('classMap/styleMap must be used in a class/style attribute.');
|
|
28
|
+
if (part.group && part.group.length !== 1) throw new Error('classMap/styleMap require a single expression.');
|
|
29
|
+
if (part.__mapKind !== value._$webjs) {
|
|
30
|
+
part.el.setAttribute(part.name, (part.statics || []).join(classes ? ' ' : ''));
|
|
31
|
+
part.__mapKind = value._$webjs;
|
|
32
|
+
part.__mapKeys = new Set();
|
|
33
|
+
part.__staticClasses = classes ? new Set(part.el.classList) : new Set();
|
|
34
|
+
}
|
|
35
|
+
const next = new Set();
|
|
36
|
+
for (const [key, v] of Object.entries(value.value)) {
|
|
37
|
+
if (classes) {
|
|
38
|
+
if (v && !part.__staticClasses.has(key)) { part.el.classList.add(key); next.add(key); }
|
|
39
|
+
} else if (v != null) {
|
|
40
|
+
const name = cssName(key); const text = String(v);
|
|
41
|
+
part.el.style.setProperty(name, text.replace(/\s*!important\s*$/, ''), /!important\s*$/.test(text) ? 'important' : '');
|
|
42
|
+
next.add(name);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const key of part.__mapKeys) if (!next.has(key)) {
|
|
46
|
+
if (classes) part.el.classList.remove(key); else part.el.style.removeProperty(key);
|
|
47
|
+
}
|
|
48
|
+
part.__mapKeys = next;
|
|
49
|
+
}
|
package/src/attribute-reader.js
CHANGED
|
@@ -39,6 +39,20 @@
|
|
|
39
39
|
* JS under a hand-written overlay, so the shared code moves out instead.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Whether a declared property has an HTML attribute at all. lit's two ways to
|
|
44
|
+
* say no, both honoured: `state: true` (internal state, which lit normalizes
|
|
45
|
+
* to `attribute: false`) and `attribute: false` (a public, property-only
|
|
46
|
+
* input). A property without one is never observed, never read from a source
|
|
47
|
+
* attribute at SSR, and never reflected; a `.prop` binding still reaches it.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ state?: boolean, attribute?: string | false } | null | undefined} decl
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function isAttributeBacked(decl) {
|
|
53
|
+
return !!decl && decl.state !== true && decl.attribute !== false;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
/**
|
|
43
57
|
* Read one attribute string into its declared property value.
|
|
44
58
|
*
|
|
@@ -110,7 +124,7 @@ export function readAttributeValue(def, value) {
|
|
|
110
124
|
// The two readers now see the SAME attribute set, so this fallback is not
|
|
111
125
|
// the only thing they agree on (#1341). Both resolve a name through
|
|
112
126
|
// `resolveAttributeProperty` below, which matches the same names
|
|
113
|
-
// `observedAttributes` lists (via `observedAttributeNames`) and nothing else, so it skips a `state: true` prop and ignores a name that
|
|
127
|
+
// `observedAttributes` lists (via `observedAttributeNames`) and nothing else, so it skips a `state: true` or `attribute: false` prop and ignores a name that
|
|
114
128
|
// maps to no attribute-backed property exactly as the observed list does.
|
|
115
129
|
// The SSR caller lowercases its source name first, because the browser
|
|
116
130
|
// lowercases while parsing. And both are handed a value with every
|
|
@@ -130,7 +144,7 @@ function hyphenate(s) {
|
|
|
130
144
|
* Resolve an attribute NAME to the property it feeds, and to that property's
|
|
131
145
|
* normalised declaration.
|
|
132
146
|
*
|
|
133
|
-
* `state: true` props are skipped, because they are absent from
|
|
147
|
+
* `state: true` and `attribute: false` props are skipped, because they are absent from
|
|
134
148
|
* `observedAttributes` and so the browser never delivers their attribute to any
|
|
135
149
|
* reader. lit reaches the same outcome the same way, resolving through the very
|
|
136
150
|
* map `observedAttributes` is built from (`reactive-element.ts`
|
|
@@ -236,7 +250,7 @@ function buildAttributeBackedProps(Cls) {
|
|
|
236
250
|
const out = [];
|
|
237
251
|
for (const [k, decl] of Object.entries(props)) {
|
|
238
252
|
const d = typeof decl === 'object' && decl !== null ? decl : { type: decl };
|
|
239
|
-
if (d
|
|
253
|
+
if (!isAttributeBacked(d)) continue;
|
|
240
254
|
const primary = d.attribute || hyphenate(k);
|
|
241
255
|
const lower = k.toLowerCase();
|
|
242
256
|
const alias = !d.attribute && lower !== primary ? lower : undefined;
|
package/src/binding-prefixes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nothing, noChange } from './sentinels.js';
|
|
1
2
|
/**
|
|
2
3
|
* The template attribute-binding prefixes the renderers recognise.
|
|
3
4
|
*
|
|
@@ -42,48 +43,16 @@ export function isBindingPrefix(ch) {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* attribute index.
|
|
50
|
-
*/
|
|
51
|
-
const BOOLEAN_ATTRIBUTES = new Set([
|
|
52
|
-
'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'controls',
|
|
53
|
-
'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'inert',
|
|
54
|
-
'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate',
|
|
55
|
-
'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected',
|
|
56
|
-
'shadowrootclonable', 'shadowrootdelegatesfocus', 'shadowrootserializable',
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* What a PLAIN attribute hole (`name=${value}`, unquoted, the whole value)
|
|
61
|
-
* writes, or `null` when the attribute is omitted. The one rule both renderers
|
|
62
|
-
* follow, so the server never serves an attribute the client then removes
|
|
63
|
-
* (#1573, #1579):
|
|
64
|
-
*
|
|
65
|
-
* - `null` / `undefined` omit the attribute. This is the "omit" value, so a
|
|
66
|
-
* conditional attribute is `aria-current=${active ? 'page' : null}`.
|
|
67
|
-
* - `false` omits it too, EXCEPT on an `aria-*` attribute, where it writes
|
|
68
|
-
* `"false"`. For a tri-state ARIA attribute (`aria-expanded`,
|
|
69
|
-
* `aria-pressed`, `aria-checked`) `"false"` and absent mean different
|
|
70
|
-
* things, so the value an author passes is the value the user agent gets.
|
|
71
|
-
* - `true` on an HTML boolean attribute (`checked`, `selected`, `disabled`,
|
|
72
|
-
* and the rest) writes the empty value, exactly as `?attr=${true}` does. Anywhere
|
|
73
|
-
* else it is `"true"`.
|
|
74
|
-
* - anything else is `String(value)`.
|
|
75
|
-
*
|
|
76
|
-
* A quoted or mixed value (`title="${x}"`, `class="a ${x}"`) is not a plain
|
|
77
|
-
* hole: the author wrote the attribute statically, so it stays and a nullish
|
|
78
|
-
* piece reads as empty text.
|
|
46
|
+
* Serialize an ordinary whole-value attribute expression for SSR and client
|
|
47
|
+
* commits. Nullish values become empty strings and booleans stringify.
|
|
48
|
+
* `nothing` omits the attribute. `noChange` omits it on initial SSR; client
|
|
49
|
+
* updates handle that sentinel before serialization to retain the prior value.
|
|
79
50
|
*
|
|
80
|
-
* @param {string} name the
|
|
81
|
-
* @param {unknown} value the
|
|
51
|
+
* @param {string} _name attribute name (kept for the shared call signature)
|
|
52
|
+
* @param {unknown} value the expression value (`live()` already unwrapped)
|
|
82
53
|
* @returns {string | null}
|
|
83
54
|
*/
|
|
84
|
-
export function attrHoleValue(
|
|
85
|
-
if (value
|
|
86
|
-
|
|
87
|
-
if (value === true && BOOLEAN_ATTRIBUTES.has(String(name).toLowerCase())) return '';
|
|
88
|
-
return String(value);
|
|
55
|
+
export function attrHoleValue(_name, value) {
|
|
56
|
+
if (value === nothing || value === noChange) return null;
|
|
57
|
+
return String(value ?? '');
|
|
89
58
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { render as clientRender } from '../render-client.js';
|
|
2
|
-
import { readAttributeValue, resolveAttributeProperty, observedAttributeNames } from '../attribute-reader.js';
|
|
2
|
+
import { readAttributeValue, resolveAttributeProperty, observedAttributeNames, isAttributeBacked } from '../attribute-reader.js';
|
|
3
3
|
import { setActiveActionSignal, activeActionSignal } from '../action-abort-client.js';
|
|
4
4
|
import { carriesFunction } from '../form-action.js';
|
|
5
5
|
import { isCSS, adoptStyles } from '../css.js';
|
|
@@ -84,7 +84,7 @@ const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undef
|
|
|
84
84
|
* @property {(newValue: unknown, oldValue: unknown) => boolean} [hasChanged]
|
|
85
85
|
* Custom dirty-check function. Called by the generated setter before
|
|
86
86
|
* scheduling an update. Return `true` to trigger a re-render, `false`
|
|
87
|
-
* to skip. Default:
|
|
87
|
+
* to skip. Default: `(a, b) => !Object.is(a, b)`.
|
|
88
88
|
*
|
|
89
89
|
* Note: this fires on the FIRST assignment too, with `oldValue` set
|
|
90
90
|
* to `undefined`. Numeric comparators that subtract against undefined
|
|
@@ -148,11 +148,16 @@ function installAccessors(Ctor, props) {
|
|
|
148
148
|
get() { return this.__propValues[propName]; },
|
|
149
149
|
set(newVal) {
|
|
150
150
|
const oldVal = this.__propValues[propName];
|
|
151
|
-
if (!changedCheck(newVal, oldVal)) return;
|
|
152
151
|
this.__propValues[propName] = newVal;
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
152
|
+
if (!changedCheck(newVal, oldVal)) return;
|
|
153
|
+
// Reflect to attribute if requested (_reflectAttribute skips a prop
|
|
154
|
+
// with no attribute: `state: true` or `attribute: false`).
|
|
155
|
+
if (d.reflect) {
|
|
156
|
+
if (this._connected) {
|
|
157
|
+
this._reflectAttribute(propName, newVal, d);
|
|
158
|
+
} else {
|
|
159
|
+
(this.__reflectPending ??= new Set()).add(propName);
|
|
160
|
+
}
|
|
156
161
|
}
|
|
157
162
|
// requestUpdate records the (name, oldValue) entry AND schedules a
|
|
158
163
|
// render. During the update phase the scheduler short-circuits and
|
|
@@ -196,8 +201,9 @@ class WebComponentBase extends Base {
|
|
|
196
201
|
* that trigger re-renders on change, coerce attribute values by type,
|
|
197
202
|
* and optionally reflect back to attributes.
|
|
198
203
|
*
|
|
199
|
-
* Properties with `state: true`
|
|
200
|
-
*
|
|
204
|
+
* Properties with `state: true` (internal state) or `attribute: false` (a
|
|
205
|
+
* property-only input) are excluded from `observedAttributes` and never
|
|
206
|
+
* reflect.
|
|
201
207
|
*
|
|
202
208
|
* @type {Record<string, PropertyDeclaration>}
|
|
203
209
|
*/
|
|
@@ -228,8 +234,8 @@ class WebComponentBase extends Base {
|
|
|
228
234
|
|
|
229
235
|
/**
|
|
230
236
|
* Returns the list of attribute names the browser should observe.
|
|
231
|
-
* Properties with `state: true` are excluded: they
|
|
232
|
-
*
|
|
237
|
+
* Properties with `state: true` or `attribute: false` are excluded: they
|
|
238
|
+
* do not correspond to any HTML attribute.
|
|
233
239
|
*
|
|
234
240
|
* @returns {string[]}
|
|
235
241
|
*/
|
|
@@ -400,6 +406,9 @@ class WebComponentBase extends Base {
|
|
|
400
406
|
* @private
|
|
401
407
|
*/
|
|
402
408
|
_reflectAttribute(propName, value, decl) {
|
|
409
|
+
// A property with no attribute (`state: true` / `attribute: false`) has
|
|
410
|
+
// nothing to reflect to.
|
|
411
|
+
if (!isAttributeBacked(decl)) return;
|
|
403
412
|
// A custom `attribute` option wins over the kebab-cased property name.
|
|
404
413
|
const attrName = decl.attribute || hyphenate(propName);
|
|
405
414
|
// Guard against re-entrant loops: attributeChangedCallback fires when
|
|
@@ -577,6 +586,17 @@ class WebComponentBase extends Base {
|
|
|
577
586
|
|
|
578
587
|
_activate() {
|
|
579
588
|
this._connected = true;
|
|
589
|
+
// A value written on the element before its class was defined is an own
|
|
590
|
+
// data property, and it hides any accessor of the same name for good.
|
|
591
|
+
// `_initializeProperties` lifts the declared properties in the
|
|
592
|
+
// constructor; an accessor the subclass defines itself (a getter/setter
|
|
593
|
+
// over a private signal, the shape internal state another element assigns
|
|
594
|
+
// takes) gets the same lift here, on the first connect, since its private
|
|
595
|
+
// field does not exist yet while the base constructor runs.
|
|
596
|
+
if (!this.__accessorsLifted) {
|
|
597
|
+
this.__accessorsLifted = true;
|
|
598
|
+
liftShadowedAccessors(this);
|
|
599
|
+
}
|
|
580
600
|
// Reflect declared reflect:true properties from their current value now
|
|
581
601
|
// that the element is connected. Constructor / willUpdate defaults were
|
|
582
602
|
// set while disconnected (the setter skips reflection then), so this is
|
|
@@ -773,9 +793,12 @@ class WebComponentBase extends Base {
|
|
|
773
793
|
// Dispose the signal watcher so dependency edges drop. Without
|
|
774
794
|
// this the element holds references to module-scope signals
|
|
775
795
|
// (and vice versa) forever.
|
|
776
|
-
|
|
777
|
-
this
|
|
778
|
-
|
|
796
|
+
for (const slot of ['__signalWatcher', '__postWatcher']) {
|
|
797
|
+
const self = /** @type {any} */ (this);
|
|
798
|
+
if (self[slot]) {
|
|
799
|
+
self[slot].dispose();
|
|
800
|
+
self[slot] = undefined;
|
|
801
|
+
}
|
|
779
802
|
}
|
|
780
803
|
for (const c of this.__controllers || NO_CONTROLLERS) {
|
|
781
804
|
if (c.hostDisconnected) c.hostDisconnected();
|
|
@@ -874,18 +897,92 @@ class WebComponentBase extends Base {
|
|
|
874
897
|
* 7. `firstUpdated(changedProperties)` runs once, on the first render only
|
|
875
898
|
* 8. `updated(changedProperties)` runs after every commit
|
|
876
899
|
* 9. Resolve `updateComplete` promise
|
|
900
|
+
*
|
|
901
|
+
* Every signal the SYNCHRONOUS part of that cycle reads is tracked, not
|
|
902
|
+
* only the ones `render()` reads, which is the span lit-labs/signals tracks
|
|
903
|
+
* by running `performUpdate` inside a computed. So a signal read only in
|
|
904
|
+
* `willUpdate()` or `updated()` (a host attribute derived from internal
|
|
905
|
+
* state) re-renders the component when it changes. Two watchers split it
|
|
906
|
+
* (see {@link _tracked}): one over steps 2-6 (`render()` included), one
|
|
907
|
+
* over the post-commit hooks, so a write `updated()` makes to something
|
|
908
|
+
* `render()` read still schedules the next update. The DOM commits are
|
|
909
|
+
* the parts left untracked (see `update()`).
|
|
877
910
|
* @private
|
|
878
911
|
*/
|
|
879
912
|
_performRender() {
|
|
880
913
|
if (!this._renderRoot) return;
|
|
914
|
+
this.__cycleRendered = false;
|
|
915
|
+
const postCommit = this._tracked('__signalWatcher', () => this._runUpdateCycle());
|
|
916
|
+
// A sync commit runs its post-commit hooks OUTSIDE the first watcher's
|
|
917
|
+
// phase, under their own watcher.
|
|
918
|
+
if (postCommit) postCommit();
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Run one phase of the update cycle with the signals it reads recorded on
|
|
923
|
+
* the watcher in `slot`, creating the watcher on first use. A watcher
|
|
924
|
+
* re-records its read set on every phase it observes, and a later change
|
|
925
|
+
* to any of those signals schedules an update.
|
|
926
|
+
*
|
|
927
|
+
* Three rules keep that safe:
|
|
928
|
+
* - A phase ignores notifications from its OWN writes. A write to a signal
|
|
929
|
+
* the phase read earlier is the phase's business, not a reason to run it
|
|
930
|
+
* again: before a commit it is already folded into this pass, and after
|
|
931
|
+
* one it is often not the component's code at all (an event `updated()`
|
|
932
|
+
* dispatches, whose listener does a read-modify-write on a store), which
|
|
933
|
+
* would otherwise loop.
|
|
934
|
+
* - A pre-commit phase that never reached `render()` (`shouldUpdate()`
|
|
935
|
+
* returned false, or a hook threw) keeps the signals the last render
|
|
936
|
+
* read on top of the ones it read itself, or a skipped cycle would
|
|
937
|
+
* silently unsubscribe the component.
|
|
938
|
+
* - A watcher disposed during its own phase (a hook that detached the
|
|
939
|
+
* host) is disposed again afterwards, since the phase kept recording.
|
|
940
|
+
*
|
|
941
|
+
* @template T
|
|
942
|
+
* @param {'__signalWatcher' | '__postWatcher'} slot
|
|
943
|
+
* @param {() => T} fn
|
|
944
|
+
* @returns {T}
|
|
945
|
+
* @private
|
|
946
|
+
*/
|
|
947
|
+
_tracked(slot, fn) {
|
|
948
|
+
const self = /** @type {any} */ (this);
|
|
949
|
+
/** @type {any} */
|
|
950
|
+
let watcher = self[slot];
|
|
951
|
+
if (!watcher) {
|
|
952
|
+
watcher = self[slot] = new Signal.subtle.Watcher(() => {
|
|
953
|
+
if (!watcher.__inPhase && this._connected) this.requestUpdate();
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
const before = slot === '__signalWatcher' ? Signal.subtle.introspectSources(watcher) : null;
|
|
957
|
+
watcher.__inPhase = true;
|
|
958
|
+
try {
|
|
959
|
+
return watcher.observe(fn);
|
|
960
|
+
} finally {
|
|
961
|
+
watcher.__inPhase = false;
|
|
962
|
+
if (before && !this.__cycleRendered) {
|
|
963
|
+
watcher.watch(...new Set([...before, ...Signal.subtle.introspectSources(watcher)]));
|
|
964
|
+
}
|
|
965
|
+
if (!this._connected) {
|
|
966
|
+
watcher.dispose();
|
|
967
|
+
if (self[slot] === watcher) self[slot] = undefined;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Steps 1-6 of the update cycle, the phase {@link _performRender} tracks
|
|
974
|
+
* with the first watcher. Returns the post-commit half to run for a sync
|
|
975
|
+
* commit, or nothing (an async commit runs it when it lands).
|
|
976
|
+
* @returns {(() => void) | undefined}
|
|
977
|
+
* @private
|
|
978
|
+
*/
|
|
979
|
+
_runUpdateCycle() {
|
|
881
980
|
|
|
882
981
|
// Hold a stable reference to the current Map so all hooks see the same
|
|
883
982
|
// snapshot. During the update phase (steps 3-6) the Map is mutated in
|
|
884
983
|
// place when property setters fire, folding those changes into THIS
|
|
885
|
-
// cycle. The Map is replaced
|
|
886
|
-
//
|
|
887
|
-
// On `shouldUpdate=false` or hook errors, the Map is preserved so the
|
|
888
|
-
// accumulated changes survive into the next render.
|
|
984
|
+
// cycle. The Map is replaced when this phase finishes, including skipped
|
|
985
|
+
// updates and hook errors, so post-commit writes belong to the NEXT cycle.
|
|
889
986
|
const changedProperties = this._changedProperties;
|
|
890
987
|
|
|
891
988
|
// --- 1. Mark we're inside an update cycle ---
|
|
@@ -923,6 +1020,7 @@ class WebComponentBase extends Base {
|
|
|
923
1020
|
// in _commitAsync.
|
|
924
1021
|
if (this.__renderAbort) this.__renderAbort.abort();
|
|
925
1022
|
this.__renderAbort = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
1023
|
+
this.__cycleRendered = true;
|
|
926
1024
|
try {
|
|
927
1025
|
const r = this.update(changedProperties);
|
|
928
1026
|
if (r && typeof r.then === 'function') pendingCommit = r;
|
|
@@ -931,26 +1029,17 @@ class WebComponentBase extends Base {
|
|
|
931
1029
|
}
|
|
932
1030
|
|
|
933
1031
|
if (!pendingCommit) {
|
|
934
|
-
// --- 6. controllers' hostUpdated (sync commit) ---
|
|
935
|
-
for (const c of this.__controllers || NO_CONTROLLERS) {
|
|
936
|
-
if (c.hostUpdated) c.hostUpdated();
|
|
937
|
-
}
|
|
938
1032
|
didCommit = true;
|
|
939
1033
|
}
|
|
940
1034
|
}
|
|
941
|
-
// shouldUpdate=false: preserve _changedProperties so the next
|
|
942
|
-
// requestUpdate keeps accumulating on top of the entries that
|
|
943
|
-
// didn't render this cycle.
|
|
944
1035
|
} catch (preCommitError) {
|
|
945
1036
|
console.error(`[webjs] lifecycle hook threw during update phase:`, preCommitError);
|
|
946
1037
|
} finally {
|
|
947
1038
|
this._isUpdating = false;
|
|
948
|
-
//
|
|
949
|
-
// render
|
|
950
|
-
//
|
|
951
|
-
|
|
952
|
-
this._changedProperties = new Map();
|
|
953
|
-
}
|
|
1039
|
+
// Consume this phase's snapshot even when shouldUpdate skips it. For an
|
|
1040
|
+
// async render, changes during the fetch start a new cycle whose token
|
|
1041
|
+
// supersedes the in-flight commit.
|
|
1042
|
+
this._changedProperties = new Map();
|
|
954
1043
|
}
|
|
955
1044
|
|
|
956
1045
|
// --- Async render: finish the cycle when the pending commit settles. ---
|
|
@@ -967,10 +1056,6 @@ class WebComponentBase extends Base {
|
|
|
967
1056
|
this.__pendingAsyncCommits--;
|
|
968
1057
|
// A newer render superseded this one; let the newer cycle finish.
|
|
969
1058
|
if (token !== this.__renderToken) return;
|
|
970
|
-
// --- 6. controllers' hostUpdated (after the async commit) ---
|
|
971
|
-
for (const c of this.__controllers || NO_CONTROLLERS) {
|
|
972
|
-
if (c.hostUpdated) c.hostUpdated();
|
|
973
|
-
}
|
|
974
1059
|
// --- 7-8 + updateComplete ---
|
|
975
1060
|
this._postCommit(changedProperties);
|
|
976
1061
|
};
|
|
@@ -1008,16 +1093,21 @@ class WebComponentBase extends Base {
|
|
|
1008
1093
|
return;
|
|
1009
1094
|
}
|
|
1010
1095
|
|
|
1011
|
-
// --- 7-8. Post-commit hooks (sync path)
|
|
1012
|
-
// updateComplete promise always
|
|
1096
|
+
// --- 7-8. Post-commit hooks (sync path), returned so they run outside
|
|
1097
|
+
// this phase. Errors are caught so the updateComplete promise always
|
|
1098
|
+
// resolves.
|
|
1013
1099
|
if (didCommit) {
|
|
1014
|
-
this._postCommit(changedProperties);
|
|
1100
|
+
return () => this._postCommit(changedProperties);
|
|
1015
1101
|
} else if (!this.__pendingAsyncCommits) {
|
|
1016
1102
|
// A non-committing cycle resolves updateComplete only when no async
|
|
1017
1103
|
// commit is in flight; otherwise the pending commit resolves it once it
|
|
1018
1104
|
// lands, so `await el.updateComplete` never returns before that DOM.
|
|
1019
1105
|
this._resolveUpdate();
|
|
1020
1106
|
}
|
|
1107
|
+
// No post-commit phase this cycle: re-arm its watcher, so a change it
|
|
1108
|
+
// was notified of meanwhile does not leave it deaf to the next one.
|
|
1109
|
+
if (!didCommit && /** @type {any} */ (this).__postWatcher) /** @type {any} */ (this).__postWatcher.watch();
|
|
1110
|
+
return undefined;
|
|
1021
1111
|
}
|
|
1022
1112
|
|
|
1023
1113
|
/**
|
|
@@ -1028,7 +1118,7 @@ class WebComponentBase extends Base {
|
|
|
1028
1118
|
*/
|
|
1029
1119
|
_resolveUpdate() {
|
|
1030
1120
|
if (this._updateResolve) {
|
|
1031
|
-
const settled = this.
|
|
1121
|
+
const settled = !this._scheduled;
|
|
1032
1122
|
this._updateResolve(settled);
|
|
1033
1123
|
this._updateResolve = null;
|
|
1034
1124
|
}
|
|
@@ -1090,7 +1180,7 @@ class WebComponentBase extends Base {
|
|
|
1090
1180
|
const props = /** @type {any} */ (this.constructor).properties || {};
|
|
1091
1181
|
for (const name of Object.keys(props)) {
|
|
1092
1182
|
const decl = props[name];
|
|
1093
|
-
if (!decl || !decl.reflect
|
|
1183
|
+
if (!decl || !decl.reflect) continue;
|
|
1094
1184
|
this._reflectAttribute(name, this[name], decl);
|
|
1095
1185
|
}
|
|
1096
1186
|
}
|
|
@@ -1154,16 +1244,14 @@ class WebComponentBase extends Base {
|
|
|
1154
1244
|
* @param {Map<string, unknown>} _changedProperties
|
|
1155
1245
|
*/
|
|
1156
1246
|
update(_changedProperties) {
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
if (!this.__signalWatcher) {
|
|
1163
|
-
this.__signalWatcher = new Signal.subtle.Watcher(() => {
|
|
1164
|
-
if (this._connected) this.requestUpdate();
|
|
1165
|
-
});
|
|
1247
|
+
const pendingReflection = this.__reflectPending;
|
|
1248
|
+
this.__reflectPending = undefined;
|
|
1249
|
+
for (const name of pendingReflection || []) {
|
|
1250
|
+
const def = this.constructor.properties[name];
|
|
1251
|
+
this._reflectAttribute(name, this[name], def);
|
|
1166
1252
|
}
|
|
1253
|
+
// The signal reads render() makes are tracked by the watcher
|
|
1254
|
+
// _performRender runs the whole cycle under.
|
|
1167
1255
|
let tpl;
|
|
1168
1256
|
// Bind this render's abort signal (#492) around the synchronous part of
|
|
1169
1257
|
// render() ONLY, so the RPC stub ties an action called there to this
|
|
@@ -1176,7 +1264,7 @@ class WebComponentBase extends Base {
|
|
|
1176
1264
|
const outer = activeActionSignal();
|
|
1177
1265
|
setActiveActionSignal(this.__renderAbort ? this.__renderAbort.signal : null);
|
|
1178
1266
|
try {
|
|
1179
|
-
|
|
1267
|
+
tpl = this.render();
|
|
1180
1268
|
} finally {
|
|
1181
1269
|
setActiveActionSignal(outer || null);
|
|
1182
1270
|
}
|
|
@@ -1191,7 +1279,10 @@ class WebComponentBase extends Base {
|
|
|
1191
1279
|
if (tpl && typeof (/** @type any */ (tpl).then) === 'function') {
|
|
1192
1280
|
return this._commitAsync(/** @type {Promise<unknown>} */ (tpl));
|
|
1193
1281
|
}
|
|
1194
|
-
|
|
1282
|
+
// The commit is untracked: a signal read while the DOM is patched (a
|
|
1283
|
+
// `watch()` part, a child's synchronous work) belongs to whatever read
|
|
1284
|
+
// it, not to this component's next render.
|
|
1285
|
+
Signal.subtle.untrack(() => clientRender(tpl, this._renderRoot, { host: this }));
|
|
1195
1286
|
return undefined;
|
|
1196
1287
|
}
|
|
1197
1288
|
|
|
@@ -1220,7 +1311,7 @@ class WebComponentBase extends Base {
|
|
|
1220
1311
|
if (this.__firstRendered && this._overridesRenderFallback()) {
|
|
1221
1312
|
try {
|
|
1222
1313
|
const fb = this.renderFallback();
|
|
1223
|
-
if (fb !== undefined) clientRender(fb, this._renderRoot);
|
|
1314
|
+
if (fb !== undefined) Signal.subtle.untrack(() => clientRender(fb, this._renderRoot, { host: this }));
|
|
1224
1315
|
} catch (e) {
|
|
1225
1316
|
console.error(`[webjs] renderFallback() threw:`, e);
|
|
1226
1317
|
}
|
|
@@ -1239,7 +1330,7 @@ class WebComponentBase extends Base {
|
|
|
1239
1330
|
// The sync path routes an identical throw to the same boundary, so
|
|
1240
1331
|
// this is what makes the two paths agree.
|
|
1241
1332
|
try {
|
|
1242
|
-
clientRender(tpl, this._renderRoot);
|
|
1333
|
+
clientRender(tpl, this._renderRoot, { host: this });
|
|
1243
1334
|
} catch (commitError) {
|
|
1244
1335
|
this._handleRenderError(
|
|
1245
1336
|
commitError instanceof Error ? commitError : new Error(String(commitError)),
|
|
@@ -1276,7 +1367,7 @@ class WebComponentBase extends Base {
|
|
|
1276
1367
|
);
|
|
1277
1368
|
try {
|
|
1278
1369
|
const fallback = this.renderError(error);
|
|
1279
|
-
if (fallback !== undefined) clientRender(fallback, this._renderRoot);
|
|
1370
|
+
if (fallback !== undefined) Signal.subtle.untrack(() => clientRender(fallback, this._renderRoot, { host: this }));
|
|
1280
1371
|
} catch (fallbackError) {
|
|
1281
1372
|
console.error(`[webjs] renderError() also threw:`, fallbackError);
|
|
1282
1373
|
}
|
|
@@ -1289,16 +1380,21 @@ class WebComponentBase extends Base {
|
|
|
1289
1380
|
* @private
|
|
1290
1381
|
*/
|
|
1291
1382
|
_postCommit(changedProperties) {
|
|
1383
|
+
const resolve = this._updateResolve;
|
|
1384
|
+
this._updateResolve = null;
|
|
1292
1385
|
try {
|
|
1293
|
-
|
|
1294
|
-
this.
|
|
1295
|
-
this.
|
|
1296
|
-
|
|
1297
|
-
|
|
1386
|
+
this._tracked('__postWatcher', () => {
|
|
1387
|
+
for (const c of this.__controllers || NO_CONTROLLERS) c.hostUpdated?.();
|
|
1388
|
+
if (!this.__firstRendered) {
|
|
1389
|
+
this.__firstRendered = true;
|
|
1390
|
+
this.firstUpdated(changedProperties);
|
|
1391
|
+
}
|
|
1392
|
+
this.updated(changedProperties);
|
|
1393
|
+
});
|
|
1298
1394
|
} catch (postCommitError) {
|
|
1299
1395
|
console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
|
|
1300
1396
|
} finally {
|
|
1301
|
-
this.
|
|
1397
|
+
resolve?.(!this._scheduled);
|
|
1302
1398
|
}
|
|
1303
1399
|
}
|
|
1304
1400
|
|
|
@@ -1529,6 +1625,28 @@ export function WebComponent(properties) {
|
|
|
1529
1625
|
}
|
|
1530
1626
|
}
|
|
1531
1627
|
|
|
1628
|
+
/**
|
|
1629
|
+
* Re-assign every own data property of `el` that hides a setter defined on
|
|
1630
|
+
* its class chain below the WebJs base, so the value goes through the
|
|
1631
|
+
* accessor instead of shadowing it.
|
|
1632
|
+
*
|
|
1633
|
+
* @param {any} el
|
|
1634
|
+
*/
|
|
1635
|
+
function liftShadowedAccessors(el) {
|
|
1636
|
+
for (const key of Object.keys(el)) {
|
|
1637
|
+
for (let p = Object.getPrototypeOf(el); p && p !== WebComponentBase.prototype; p = Object.getPrototypeOf(p)) {
|
|
1638
|
+
const d = Object.getOwnPropertyDescriptor(p, key);
|
|
1639
|
+
if (!d) continue;
|
|
1640
|
+
if (d.set) {
|
|
1641
|
+
const value = el[key];
|
|
1642
|
+
delete el[key];
|
|
1643
|
+
el[key] = value;
|
|
1644
|
+
}
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1532
1650
|
// Ensure static inheritance and instanceof checks work
|
|
1533
1651
|
Object.setPrototypeOf(WebComponent, WebComponentBase);
|
|
1534
1652
|
WebComponent.prototype = WebComponentBase.prototype;
|