@webjsdev/core 0.7.50 → 0.7.53
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 +20 -20
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +3 -3
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.js +2 -1896
- package/src/css.d.ts +9 -0
- package/src/escape.d.ts +2 -0
- package/src/html.d.ts +9 -0
- package/src/render-client/parts.js +2039 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +938 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +206 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +182 -0
- package/src/router-client/fetch-apply.js +432 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +952 -0
- package/src/router-client/prefetch.js +831 -0
- package/src/router-client/scroll.js +308 -0
- package/src/router-client/snapshot-cache.js +113 -0
- package/src/router-client/state.js +155 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +454 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +11 -2
- package/src/router-client.js +78 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
- package/src/suspense.d.ts +9 -0
- package/src/websocket-client.d.ts +22 -0
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
import { render as clientRender } from '../render-client.js';
|
|
2
|
+
import { readAttributeValue, resolveAttributeProperty } from '../attribute-reader.js';
|
|
3
|
+
import { setActiveActionSignal } from '../action-abort-client.js';
|
|
4
|
+
import { carriesFunction } from '../form-action.js';
|
|
5
|
+
import { isCSS, adoptStyles } from '../css.js';
|
|
6
|
+
import { register, tagOf } from '../registry.js';
|
|
7
|
+
import { parse as deserializeProp } from '../serialize.js';
|
|
8
|
+
import { Signal } from '../signal.js';
|
|
9
|
+
import {
|
|
10
|
+
captureAuthoredChildren,
|
|
11
|
+
adoptSSRAssignments,
|
|
12
|
+
ensureSlotState,
|
|
13
|
+
hasSlotState,
|
|
14
|
+
hasFrameworkRenderedSubtree,
|
|
15
|
+
installSlotInterception,
|
|
16
|
+
installSlotSensors,
|
|
17
|
+
teardownSlotSensors,
|
|
18
|
+
reconnectSweep,
|
|
19
|
+
} from '../slot.js';
|
|
20
|
+
import { ServerElement } from './server-element.js';
|
|
21
|
+
import {
|
|
22
|
+
defaultHasChanged,
|
|
23
|
+
safeString,
|
|
24
|
+
warnFunctionReflection,
|
|
25
|
+
warnUnserializableReflection,
|
|
26
|
+
prop,
|
|
27
|
+
} from './reactive.js';
|
|
28
|
+
|
|
29
|
+
const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undefined';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object} ReactiveController
|
|
33
|
+
* A controller is a reusable piece of lifecycle logic that plugs into a
|
|
34
|
+
* WebComponent host. Controllers let you extract cross-cutting concerns
|
|
35
|
+
* (timers, intersection observers, media queries, form validation, fetch
|
|
36
|
+
* caching) out of the component class and share them across unrelated
|
|
37
|
+
* components.
|
|
38
|
+
*
|
|
39
|
+
* **When to use:** any time two or more components need the same
|
|
40
|
+
* `connectedCallback` / `disconnectedCallback` / pre-render / post-render
|
|
41
|
+
* behaviour. Instead of a mixin or base-class hierarchy, attach a controller.
|
|
42
|
+
*
|
|
43
|
+
* **Why it exists:** mirrors Lit's `ReactiveController` protocol so
|
|
44
|
+
* ecosystem controllers are interoperable.
|
|
45
|
+
*
|
|
46
|
+
* @property {() => void} [hostConnected]
|
|
47
|
+
* Called when the host element is inserted into the DOM
|
|
48
|
+
* (`connectedCallback`). Use for subscriptions, observers, timers.
|
|
49
|
+
* @property {() => void} [hostDisconnected]
|
|
50
|
+
* Called when the host element is removed from the DOM
|
|
51
|
+
* (`disconnectedCallback`). Use for cleanup: unsubscribe, disconnect
|
|
52
|
+
* observers, clear timers.
|
|
53
|
+
* @property {() => void} [hostUpdate]
|
|
54
|
+
* Called just before the host renders (inside `_performRender`, after
|
|
55
|
+
* `willUpdate` but before `render()`). Use for reading layout or
|
|
56
|
+
* preparing data that the render depends on.
|
|
57
|
+
* @property {() => void} [hostUpdated]
|
|
58
|
+
* Called after the host has rendered and the DOM is up to date. Use for
|
|
59
|
+
* post-render side effects that depend on the new DOM (measuring,
|
|
60
|
+
* focusing, scrolling).
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {Object} PropertyDeclaration
|
|
65
|
+
* Declares how a single reactive property behaves. Used inside
|
|
66
|
+
* `static properties = { propName: { …declaration } }`.
|
|
67
|
+
*
|
|
68
|
+
* @property {Function} [type]
|
|
69
|
+
* Constructor used for attribute → property coercion.
|
|
70
|
+
* Supported built-ins: `String`, `Number`, `Boolean`, `Object`, `Array`.
|
|
71
|
+
* Default: `String`.
|
|
72
|
+
*
|
|
73
|
+
* @property {boolean} [reflect]
|
|
74
|
+
* When `true`, writing to the property also sets the corresponding
|
|
75
|
+
* HTML attribute on the element (kebab-cased). Useful when you want
|
|
76
|
+
* CSS attribute selectors like `my-el[mode="dark"]` to work.
|
|
77
|
+
*
|
|
78
|
+
* @property {boolean} [state]
|
|
79
|
+
* When `true`, the property is *internal-only*: it is NOT exposed as an
|
|
80
|
+
* HTML attribute (excluded from `observedAttributes`) and never reflects.
|
|
81
|
+
* It still triggers a re-render when changed via the generated setter.
|
|
82
|
+
* Use for private reactive state that shouldn't leak into the DOM.
|
|
83
|
+
*
|
|
84
|
+
* @property {(newValue: unknown, oldValue: unknown) => boolean} [hasChanged]
|
|
85
|
+
* Custom dirty-check function. Called by the generated setter before
|
|
86
|
+
* scheduling an update. Return `true` to trigger a re-render, `false`
|
|
87
|
+
* to skip. Default: strict inequality `(a, b) => a !== b`.
|
|
88
|
+
*
|
|
89
|
+
* Note: this fires on the FIRST assignment too, with `oldValue` set
|
|
90
|
+
* to `undefined`. Numeric comparators that subtract against undefined
|
|
91
|
+
* produce `NaN`, which evaluates to `false`, silently rejecting the
|
|
92
|
+
* constructor's initial assignment. Treat `oldValue === undefined` as
|
|
93
|
+
* "always changed" in custom comparators so the initial value lands.
|
|
94
|
+
*
|
|
95
|
+
* @property {{ fromAttribute: (value: string|null, type?: Function) => unknown, toAttribute: (value: unknown, type?: Function) => string|null }} [converter]
|
|
96
|
+
* Custom serialization/deserialization pair for the HTML attribute.
|
|
97
|
+
* `fromAttribute` is called by BOTH attribute readers through the shared
|
|
98
|
+
* `readAttributeValue` in `attribute-reader.js`: the client
|
|
99
|
+
* `attributeChangedCallback` and the SSR
|
|
100
|
+
* `applyAttrsToInstance` in `render-server.js` (#1340). So it runs
|
|
101
|
+
* server-side too, and must not touch browser globals.
|
|
102
|
+
* `toAttribute` is called when reflecting back to the attribute.
|
|
103
|
+
* If omitted, the built-in type-based coercion is used.
|
|
104
|
+
*/
|
|
105
|
+
// Base class choice: real HTMLElement on the browser, the shim on the server.
|
|
106
|
+
const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Marker stamped on the anonymous subclass the `WebComponent({...})` factory
|
|
110
|
+
* produces. It lets `_assertFactoryProperties` tell the framework's own
|
|
111
|
+
* factory-generated `static properties` (allowed) apart from a `static
|
|
112
|
+
* properties` a user wrote by hand in a class body (no longer allowed).
|
|
113
|
+
*/
|
|
114
|
+
const FACTORY_PROPS = Symbol('webjs.factoryProps');
|
|
115
|
+
// Per-class memo so the constructor-time enforcement walk runs once per class.
|
|
116
|
+
const _propsChecked = new WeakSet();
|
|
117
|
+
|
|
118
|
+
class WebComponentBase extends Base {
|
|
119
|
+
/** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
|
|
120
|
+
static shadow = false;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Hydration strategy for this component.
|
|
124
|
+
*
|
|
125
|
+
* **AI hint:** Set `static hydrate = 'visible'` to defer client-side
|
|
126
|
+
* hydration until the element scrolls into (or near) the viewport. The
|
|
127
|
+
* server-rendered Declarative Shadow DOM content stays visible the whole
|
|
128
|
+
* time: users see the SSR HTML immediately while JavaScript activation
|
|
129
|
+
* is deferred. This is useful for below-the-fold components that don't
|
|
130
|
+
* need interactivity right away.
|
|
131
|
+
*
|
|
132
|
+
* - `undefined` (default): hydrate immediately on `connectedCallback`.
|
|
133
|
+
* - `'visible'`: hydrate when the element enters the viewport
|
|
134
|
+
* (with a 200 px root margin).
|
|
135
|
+
*
|
|
136
|
+
* @type {'visible' | undefined}
|
|
137
|
+
*/
|
|
138
|
+
static hydrate = undefined;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Attribute/property declarations.
|
|
142
|
+
*
|
|
143
|
+
* Each key is a property name; the value is a {@link PropertyDeclaration}.
|
|
144
|
+
*
|
|
145
|
+
* Properties declared here get auto-generated accessors (getter/setter)
|
|
146
|
+
* that trigger re-renders on change, coerce attribute values by type,
|
|
147
|
+
* and optionally reflect back to attributes.
|
|
148
|
+
*
|
|
149
|
+
* Properties with `state: true` are excluded from `observedAttributes`
|
|
150
|
+
* and never reflect: they behave like private reactive state.
|
|
151
|
+
*
|
|
152
|
+
* @type {Record<string, PropertyDeclaration>}
|
|
153
|
+
*/
|
|
154
|
+
static properties = {};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Styles to adopt into the shadow root.
|
|
158
|
+
* @type {import('../css.js').CSSResult | import('../css.js').CSSResult[] | null}
|
|
159
|
+
*/
|
|
160
|
+
static styles = null;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Register this class as a custom element under `tag`.
|
|
164
|
+
*
|
|
165
|
+
* class Counter extends WebComponent { … }
|
|
166
|
+
* Counter.register('my-counter');
|
|
167
|
+
*
|
|
168
|
+
* Delegates to `customElements.define` (or the server-side shim) via the
|
|
169
|
+
* internal registry. The module URL for `<link rel="modulepreload">`
|
|
170
|
+
* hints is derived server-side by scanning the app tree: no need to
|
|
171
|
+
* pass `import.meta.url`.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} tag Must contain a hyphen (HTML spec).
|
|
174
|
+
*/
|
|
175
|
+
static register(tag) {
|
|
176
|
+
register(tag, this);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Returns the list of attribute names the browser should observe.
|
|
181
|
+
* Properties with `state: true` are excluded: they are internal-only
|
|
182
|
+
* and do not correspond to any HTML attribute.
|
|
183
|
+
*
|
|
184
|
+
* @returns {string[]}
|
|
185
|
+
*/
|
|
186
|
+
static get observedAttributes() {
|
|
187
|
+
const props = this.properties || {};
|
|
188
|
+
return Object.keys(props)
|
|
189
|
+
.filter((k) => !(typeof props[k] === 'object' && props[k].state))
|
|
190
|
+
.map((k) => (typeof props[k] === 'object' && props[k].attribute) || hyphenate(k));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
constructor() {
|
|
194
|
+
super();
|
|
195
|
+
this._renderRoot = null;
|
|
196
|
+
this._scheduled = false;
|
|
197
|
+
this._connected = false;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Set of attached reactive controllers.
|
|
201
|
+
* @type {Set<ReactiveController>}
|
|
202
|
+
*/
|
|
203
|
+
this.__controllers = new Set();
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether the component has completed its first render.
|
|
207
|
+
* Used to gate the one-time `firstUpdated()` call.
|
|
208
|
+
* @type {boolean}
|
|
209
|
+
*/
|
|
210
|
+
this.__firstRendered = false;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Map of changed properties accumulated since the last render. Keys are
|
|
214
|
+
* property names; values are the previous value before the change. Passed
|
|
215
|
+
* to `shouldUpdate`, `willUpdate`,
|
|
216
|
+
* `update`, `firstUpdated`, and `updated`. Cleared at the start of each
|
|
217
|
+
* render cycle so accumulations during hooks queue for the next cycle.
|
|
218
|
+
* @type {Map<string, unknown>}
|
|
219
|
+
*/
|
|
220
|
+
this._changedProperties = new Map();
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolver for the currently-pending updateComplete promise. `null` when
|
|
224
|
+
* no update is pending.
|
|
225
|
+
* @type {((value: boolean) => void) | null}
|
|
226
|
+
* @private
|
|
227
|
+
*/
|
|
228
|
+
this._updateResolve = null;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Promise that resolves after the next render commit. Resolves to `true`
|
|
232
|
+
* when there are no further pending updates, `false` otherwise.
|
|
233
|
+
* @type {Promise<boolean>}
|
|
234
|
+
* @private
|
|
235
|
+
*/
|
|
236
|
+
this._updatePromise = Promise.resolve(true);
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Set while the component is inside the update phase (between
|
|
240
|
+
* `shouldUpdate` and `updated`). Property assignments during this window
|
|
241
|
+
* fold into the CURRENT `changedProperties` Map without scheduling a
|
|
242
|
+
* new microtask render. Assignments during `updated()` (after the flag
|
|
243
|
+
* clears) queue a fresh cycle.
|
|
244
|
+
* @type {boolean}
|
|
245
|
+
* @private
|
|
246
|
+
*/
|
|
247
|
+
this._isUpdating = false;
|
|
248
|
+
|
|
249
|
+
// Enforce the declare-free factory DX: a hand-written `static properties`
|
|
250
|
+
// in a class body is a hard error (use `extends WebComponent({ … })`).
|
|
251
|
+
this._assertFactoryProperties();
|
|
252
|
+
|
|
253
|
+
// Install reactive property accessors for `static properties` declarations.
|
|
254
|
+
this._initializeProperties();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Throw if a class in this instance's constructor chain declares its own
|
|
259
|
+
* `static properties`. Reactive properties must be declared via the
|
|
260
|
+
* `extends WebComponent({ … })` factory, which stamps {@link FACTORY_PROPS}
|
|
261
|
+
* on the subclass it generates; a `static properties` written by hand in a
|
|
262
|
+
* class body carries no such marker and is rejected here (issue #598).
|
|
263
|
+
*
|
|
264
|
+
* The walk stops at {@link WebComponentBase} (whose `static properties = {}`
|
|
265
|
+
* default is internal) and is memoized per class so it runs once.
|
|
266
|
+
* @private
|
|
267
|
+
*/
|
|
268
|
+
_assertFactoryProperties() {
|
|
269
|
+
const Ctor = /** @type {any} */ (this.constructor);
|
|
270
|
+
if (_propsChecked.has(Ctor)) return;
|
|
271
|
+
let C = Ctor;
|
|
272
|
+
while (C && C !== WebComponentBase) {
|
|
273
|
+
if (Object.hasOwn(C, 'properties') && !Object.hasOwn(C, FACTORY_PROPS)) {
|
|
274
|
+
const name = C.name || 'a component';
|
|
275
|
+
throw new Error(
|
|
276
|
+
`${name}: \`static properties\` is no longer supported. Declare reactive ` +
|
|
277
|
+
`properties via the factory instead: \`class ${name} extends WebComponent({ ` +
|
|
278
|
+
`count: Number })\`. Use the \`prop()\` helper for options ` +
|
|
279
|
+
`(\`prop(Number, { reflect: true })\`) and set defaults in the ` +
|
|
280
|
+
`constructor after \`super()\`. See https://webjs.dev/docs/components.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
C = Object.getPrototypeOf(C);
|
|
284
|
+
}
|
|
285
|
+
_propsChecked.add(Ctor);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* For every key in `static properties`, create a getter/setter pair on
|
|
290
|
+
* the instance that coerces values, runs `hasChanged`, schedules updates,
|
|
291
|
+
* and optionally reflects to the HTML attribute.
|
|
292
|
+
*
|
|
293
|
+
* This is called once from the constructor. The backing store is a plain
|
|
294
|
+
* object (`this.__propValues`) so accessors don't collide with the
|
|
295
|
+
* prototype.
|
|
296
|
+
* @private
|
|
297
|
+
*/
|
|
298
|
+
_initializeProperties() {
|
|
299
|
+
const Ctor = /** @type {any} */ (this.constructor);
|
|
300
|
+
const props = Ctor.properties;
|
|
301
|
+
if (!props || typeof props !== 'object') return;
|
|
302
|
+
|
|
303
|
+
/** @type {Record<string, unknown>} */
|
|
304
|
+
this.__propValues = {};
|
|
305
|
+
|
|
306
|
+
for (const [propName, decl] of Object.entries(props)) {
|
|
307
|
+
const d = typeof decl === 'object' ? decl : { type: decl };
|
|
308
|
+
// Capture any value set before the accessor was installed (e.g. via
|
|
309
|
+
// attribute or property assignment before `super()` returns).
|
|
310
|
+
const initial = /** @type {any} */ (this)[propName];
|
|
311
|
+
|
|
312
|
+
Object.defineProperty(this, propName, {
|
|
313
|
+
configurable: true,
|
|
314
|
+
enumerable: true,
|
|
315
|
+
get: () => this.__propValues[propName],
|
|
316
|
+
set: (newVal) => {
|
|
317
|
+
const oldVal = this.__propValues[propName];
|
|
318
|
+
const changed = (d.hasChanged || defaultHasChanged)(newVal, oldVal);
|
|
319
|
+
if (!changed) return;
|
|
320
|
+
this.__propValues[propName] = newVal;
|
|
321
|
+
|
|
322
|
+
// Reflect to attribute if requested (and not internal state).
|
|
323
|
+
if (d.reflect && !d.state && this._connected) {
|
|
324
|
+
this._reflectAttribute(propName, newVal, d);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// requestUpdate records the (name, oldValue) entry AND schedules
|
|
328
|
+
// a render. When called during the update phase (willUpdate /
|
|
329
|
+
// hostUpdate / update / hostUpdated), the scheduler short-circuits
|
|
330
|
+
// and the entry folds into the current cycle's changedProperties.
|
|
331
|
+
this.requestUpdate(propName, oldVal);
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (initial !== undefined) {
|
|
336
|
+
this.__propValues[propName] = initial;
|
|
337
|
+
} else if (d.default !== undefined) {
|
|
338
|
+
// Declarative `default` option (lit-parity). A function default is
|
|
339
|
+
// CALLED per instance, so an object / array default is a fresh value
|
|
340
|
+
// per element. Written straight to the backing store; an applied
|
|
341
|
+
// attribute (attributeChangedCallback runs later) overrides it.
|
|
342
|
+
this.__propValues[propName] =
|
|
343
|
+
typeof d.default === 'function' ? d.default() : d.default;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Write a property value back to its corresponding HTML attribute.
|
|
350
|
+
* Uses a custom `converter.toAttribute` if provided, otherwise the
|
|
351
|
+
* built-in type-based serialization.
|
|
352
|
+
*
|
|
353
|
+
* @param {string} propName
|
|
354
|
+
* @param {unknown} value
|
|
355
|
+
* @param {PropertyDeclaration} decl
|
|
356
|
+
* @private
|
|
357
|
+
*/
|
|
358
|
+
_reflectAttribute(propName, value, decl) {
|
|
359
|
+
// A custom `attribute` option wins over the kebab-cased property name.
|
|
360
|
+
const attrName = decl.attribute || hyphenate(propName);
|
|
361
|
+
// Guard against re-entrant loops: attributeChangedCallback fires when
|
|
362
|
+
// we call setAttribute, which would call the setter again.
|
|
363
|
+
if (this.__reflectingAttribute) return;
|
|
364
|
+
this.__reflectingAttribute = true;
|
|
365
|
+
try {
|
|
366
|
+
if (decl.converter && decl.converter.toAttribute) {
|
|
367
|
+
const serialized = decl.converter.toAttribute(value, decl.type);
|
|
368
|
+
if (serialized == null) this.removeAttribute(attrName);
|
|
369
|
+
else this.setAttribute(attrName, serialized);
|
|
370
|
+
} else if (typeof value === 'function') {
|
|
371
|
+
// #1169: the value IS a function, and no branch below has a
|
|
372
|
+
// meaningful serialization for one. `String(fn)` is the function's
|
|
373
|
+
// SOURCE, so a reflected `'use server'` action would ship its whole
|
|
374
|
+
// body, closure secrets included, to every visitor, and
|
|
375
|
+
// `JSON.stringify(fn)` is `undefined`, which `setAttribute` writes as
|
|
376
|
+
// the literal string "undefined". Treat it like `null` and remove the
|
|
377
|
+
// attribute, matching what the `.prop=${fn}` SSR binding already does
|
|
378
|
+
// for an unserializable value.
|
|
379
|
+
//
|
|
380
|
+
// Placed AFTER the converter branch on purpose: a custom
|
|
381
|
+
// `toAttribute` is author-controlled, so its author has taken
|
|
382
|
+
// responsibility for serializing whatever they are handed.
|
|
383
|
+
this.removeAttribute(attrName);
|
|
384
|
+
warnFunctionReflection(this, propName, attrName);
|
|
385
|
+
} else if (decl.type === Boolean) {
|
|
386
|
+
if (value) this.setAttribute(attrName, '');
|
|
387
|
+
else this.removeAttribute(attrName);
|
|
388
|
+
} else if (value == null) {
|
|
389
|
+
this.removeAttribute(attrName);
|
|
390
|
+
} else if (decl.type === Object || decl.type === Array) {
|
|
391
|
+
// One rule about an unserializable reflected value, in two halves.
|
|
392
|
+
//
|
|
393
|
+
// A JSON-typed prop CARRYING a function is safe and stays whole:
|
|
394
|
+
// `JSON.stringify` drops a function to `null` in an array and omits
|
|
395
|
+
// the key in an object, so `[1, 2, fn]` serializes to `[1,2,null]`
|
|
396
|
+
// with no source and no data loss. Refusing here would discard the
|
|
397
|
+
// `1` and the `2` on a path that never leaked.
|
|
398
|
+
//
|
|
399
|
+
// A value `JSON.stringify` cannot serialize AT ALL is the other half
|
|
400
|
+
// (#1253): a cycle, a `BigInt`, or an author `toJSON()` that throws.
|
|
401
|
+
// The throw used to escape reflection entirely, so a property
|
|
402
|
+
// assignment threw from the setter, a client upgrade threw out of
|
|
403
|
+
// `connectedCallback` before the first render, and an SSR render was
|
|
404
|
+
// swallowed by per-component isolation, which surfaces an error box in
|
|
405
|
+
// dev and emits the component EMPTY at a 200 in PRODUCTION, where the
|
|
406
|
+
// cause is only in the server log. There is no string to put in the
|
|
407
|
+
// attribute, so the
|
|
408
|
+
// attribute goes, exactly as it does for a function. The `catch` IS
|
|
409
|
+
// the detection: `JSON.stringify` already walks the value, so its own
|
|
410
|
+
// failure covers all three causes, where a cycle-only pre-walk would
|
|
411
|
+
// miss the other two and pay a full traversal per reflection.
|
|
412
|
+
let serialized;
|
|
413
|
+
let serializable = true;
|
|
414
|
+
try {
|
|
415
|
+
serialized = JSON.stringify(value);
|
|
416
|
+
} catch (e) {
|
|
417
|
+
serializable = false;
|
|
418
|
+
this.removeAttribute(attrName);
|
|
419
|
+
warnUnserializableReflection(this, propName, attrName, e && e.message);
|
|
420
|
+
}
|
|
421
|
+
// Outside the `try` on purpose, so a genuine `setAttribute` failure
|
|
422
|
+
// (an invalid attribute name) still surfaces instead of being folded
|
|
423
|
+
// into the unserializable path.
|
|
424
|
+
if (serializable) this.setAttribute(attrName, serialized);
|
|
425
|
+
} else if (carriesFunction(value)) {
|
|
426
|
+
// The string fall-through is the one place a CARRIED function still
|
|
427
|
+
// leaks. `String([fn])` is `Array.prototype.join`, which runs
|
|
428
|
+
// `String()` on every element, so `[serverAction]` writes the same
|
|
429
|
+
// source `serverAction` does. Hence the recursive predicate the
|
|
430
|
+
// form-action guard already uses, rather than a bare `typeof`, but
|
|
431
|
+
// scoped to this branch rather than applied above, where JSON
|
|
432
|
+
// handles the same shape losslessly.
|
|
433
|
+
this.removeAttribute(attrName);
|
|
434
|
+
warnFunctionReflection(this, propName, attrName);
|
|
435
|
+
} else {
|
|
436
|
+
this.setAttribute(attrName, String(value));
|
|
437
|
+
}
|
|
438
|
+
} finally {
|
|
439
|
+
this.__reflectingAttribute = false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
connectedCallback() {
|
|
444
|
+
if (!isBrowser) return;
|
|
445
|
+
|
|
446
|
+
// Apply any `data-webjs-prop-*` attributes emitted by SSR. The server
|
|
447
|
+
// emits these for `.prop=${val}` bindings in parent templates so
|
|
448
|
+
// rich-typed values (Array, Object, Date, Map, Set, BigInt, cycles)
|
|
449
|
+
// round-trip through the rendered HTML. Once applied, the attributes
|
|
450
|
+
// are stripped so the settled DOM matches what the user would expect
|
|
451
|
+
// from the JS source: no framework artifacts left on the element.
|
|
452
|
+
// One-time per element. Subsequent reconnections do nothing.
|
|
453
|
+
if (!this.__webjsPropsHydrated) {
|
|
454
|
+
this.__webjsPropsHydrated = true;
|
|
455
|
+
this._hydratePropAttrs();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const Ctor = /** @type any */ (this.constructor);
|
|
459
|
+
|
|
460
|
+
// Selective hydration: defer activation until the element scrolls into
|
|
461
|
+
// (or near) the viewport. The DSD content from SSR stays visible the
|
|
462
|
+
// whole time: the user sees the server-rendered HTML.
|
|
463
|
+
if (
|
|
464
|
+
Ctor.hydrate === 'visible' &&
|
|
465
|
+
typeof IntersectionObserver !== 'undefined' &&
|
|
466
|
+
!this.__hydrationActivated
|
|
467
|
+
) {
|
|
468
|
+
this.__hydrationActivated = false;
|
|
469
|
+
this.__hydrationObserver = new IntersectionObserver(
|
|
470
|
+
(entries) => {
|
|
471
|
+
for (const entry of entries) {
|
|
472
|
+
if (entry.isIntersecting) {
|
|
473
|
+
this.__hydrationObserver.unobserve(this);
|
|
474
|
+
this.__hydrationObserver.disconnect();
|
|
475
|
+
this.__hydrationObserver = null;
|
|
476
|
+
this.__hydrationActivated = true;
|
|
477
|
+
this._activate();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
{ rootMargin: '200px' }
|
|
483
|
+
);
|
|
484
|
+
this.__hydrationObserver.observe(this);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
this._activate();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Internal activation method that performs the actual connectedCallback
|
|
493
|
+
* work: setting up the render root, adopting styles, notifying
|
|
494
|
+
* controllers, and performing the first render.
|
|
495
|
+
*
|
|
496
|
+
* Called directly from `connectedCallback()` for normal components, or
|
|
497
|
+
* deferred via IntersectionObserver when `static hydrate = 'visible'`.
|
|
498
|
+
*
|
|
499
|
+
* @private
|
|
500
|
+
*/
|
|
501
|
+
/**
|
|
502
|
+
* Read `data-webjs-prop-*` attributes (emitted by SSR for `.prop=${val}`
|
|
503
|
+
* bindings in parent templates), decode each via the wire serializer,
|
|
504
|
+
* assign the decoded value to the corresponding camelCase property on
|
|
505
|
+
* this instance, and remove the attribute from the DOM. After this
|
|
506
|
+
* runs, inspecting the element shows the same attributes the developer
|
|
507
|
+
* would expect from the JS source.
|
|
508
|
+
*
|
|
509
|
+
* @private
|
|
510
|
+
*/
|
|
511
|
+
_hydratePropAttrs() {
|
|
512
|
+
/** @type {string[]} */
|
|
513
|
+
const names = [];
|
|
514
|
+
const attrs = this.attributes;
|
|
515
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
516
|
+
const n = attrs[i].name;
|
|
517
|
+
if (n.startsWith('data-webjs-prop-')) names.push(n);
|
|
518
|
+
}
|
|
519
|
+
for (const fullName of names) {
|
|
520
|
+
const raw = this.getAttribute(fullName);
|
|
521
|
+
this.removeAttribute(fullName);
|
|
522
|
+
if (raw == null) continue;
|
|
523
|
+
const propName = camelCase(fullName.slice('data-webjs-prop-'.length));
|
|
524
|
+
try {
|
|
525
|
+
/** @type any */ (this)[propName] = deserializeProp(raw);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
console.warn(
|
|
528
|
+
`[webjs] failed to decode ${fullName} on <${this.tagName.toLowerCase()}>: ${err && err.message}`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
_activate() {
|
|
535
|
+
this._connected = true;
|
|
536
|
+
// Reflect declared reflect:true properties from their current value now
|
|
537
|
+
// that the element is connected. Constructor / willUpdate defaults were
|
|
538
|
+
// set while disconnected (the setter skips reflection then), so this is
|
|
539
|
+
// what makes a freshly-created client element carry the same reflected
|
|
540
|
+
// attributes the SSR walker emitted. Same-value reflects are no-ops, so a
|
|
541
|
+
// hydrated element (whose attribute already arrived from SSR) is unchanged.
|
|
542
|
+
this._reflectDeclaredAttributes();
|
|
543
|
+
const Ctor = /** @type any */ (this.constructor);
|
|
544
|
+
// Mark LIGHT-DOM component hosts so the framework default host rule
|
|
545
|
+
// (`@layer webjs-host { :where([data-wj-host]) { display: block } }`)
|
|
546
|
+
// applies. SSR already stamps this on server-rendered light hosts
|
|
547
|
+
// (idempotent here); this also covers a client-only light component (never
|
|
548
|
+
// SSR'd) so it does not collapse. Shadow hosts are NOT marked: a document
|
|
549
|
+
// rule would override the shadow author's own `:host { display: … }`, so
|
|
550
|
+
// shadow components control their host display via `:host` in `static styles`.
|
|
551
|
+
if (Ctor.shadow !== true && !this.hasAttribute('data-wj-host')) {
|
|
552
|
+
this.setAttribute('data-wj-host', '');
|
|
553
|
+
}
|
|
554
|
+
if (Ctor.shadow === true) {
|
|
555
|
+
const hadSSRShadow = !!this.shadowRoot;
|
|
556
|
+
if (!this.shadowRoot) {
|
|
557
|
+
/** @type any */ (this).attachShadow({ mode: 'open' });
|
|
558
|
+
}
|
|
559
|
+
this._renderRoot = this.shadowRoot;
|
|
560
|
+
const styles = Ctor.styles;
|
|
561
|
+
const list = Array.isArray(styles) ? styles : isCSS(styles) ? [styles] : [];
|
|
562
|
+
if (list.length) {
|
|
563
|
+
// If the shadow root came from Declarative Shadow DOM (SSR), it
|
|
564
|
+
// contains an inline <style> tag. Remove it before switching to
|
|
565
|
+
// adoptedStyleSheets to avoid duplicate styles.
|
|
566
|
+
if (hadSSRShadow) {
|
|
567
|
+
const ssrStyle = this.shadowRoot.querySelector('style');
|
|
568
|
+
if (ssrStyle) ssrStyle.remove();
|
|
569
|
+
}
|
|
570
|
+
adoptStyles(this._renderRoot, list);
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
this._renderRoot = this;
|
|
574
|
+
// Light DOM: static styles is not supported (no shadow root for
|
|
575
|
+
// adoptedStyleSheets). Warn if the developer set both.
|
|
576
|
+
if (Ctor.styles) {
|
|
577
|
+
console.warn(
|
|
578
|
+
`[webjs] <${tagOf(Ctor) || this.tagName?.toLowerCase()}> has static shadow = false AND static styles. ` +
|
|
579
|
+
`static styles only works with shadow DOM (adoptedStyleSheets). ` +
|
|
580
|
+
`For light DOM, use global CSS or <style> in render().`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
// Light-DOM slot capture: ONCE per host lifetime (#1015). Three
|
|
584
|
+
// sub-paths, all of which resolve to "the record is populated
|
|
585
|
+
// exactly once and never re-captured":
|
|
586
|
+
//
|
|
587
|
+
// a. Reconnection. The record already exists from a prior mount.
|
|
588
|
+
// The host still carries the rendered template DOM (plus
|
|
589
|
+
// placed slot children) from before the disconnect. Skip
|
|
590
|
+
// capture (would wrongly hoover up rendered nodes) and skip
|
|
591
|
+
// SSR adoption. clientRender will see the existing INSTANCE
|
|
592
|
+
// and updateInstance instead of recreating; the DOM stays.
|
|
593
|
+
//
|
|
594
|
+
// b. SSR hydration (first mount, <!--webjs-hydrate--> marker
|
|
595
|
+
// present). Children are already inside their
|
|
596
|
+
// <slot data-webjs-light data-projection="actual"> elements
|
|
597
|
+
// (injectDSD placed them). Adopt those assignments BEFORE
|
|
598
|
+
// _performRender so we retain references to the SSR'd nodes;
|
|
599
|
+
// the renderer's createInstance().replaceChildren() will
|
|
600
|
+
// detach them, but the slot-apply step re-attaches the same
|
|
601
|
+
// Node refs into the freshly-cloned slot. DOM identity
|
|
602
|
+
// preserved through the hydration round-trip.
|
|
603
|
+
//
|
|
604
|
+
// c. First mount, no SSR. Partition authored children into the
|
|
605
|
+
// record before _performRender wipes the host.
|
|
606
|
+
//
|
|
607
|
+
// After capture, installSlotInterception + installSlotSensors below make
|
|
608
|
+
// native DOM writes on a mounted host live (appendChild, slot= flips,
|
|
609
|
+
// innerHTML, HTMLSlotElement.assign), so light-DOM slots match shadow DOM
|
|
610
|
+
// through the standard API.
|
|
611
|
+
if (hasSlotState(this)) {
|
|
612
|
+
// (a) Reconnection. Record already populated. Sweep any direct child
|
|
613
|
+
// added by a raw bypass write while the host was disconnected (no
|
|
614
|
+
// sensor was live to catch it), then re-arm the sensors below.
|
|
615
|
+
reconnectSweep(this);
|
|
616
|
+
} else if (
|
|
617
|
+
this.__isHydrating() ||
|
|
618
|
+
this.hasAttribute('data-wj-serialized') ||
|
|
619
|
+
hasFrameworkRenderedSubtree(this)
|
|
620
|
+
) {
|
|
621
|
+
// (b) Framework-rendered markup, from any of three signals: boot-time
|
|
622
|
+
// SSR hydration (the webjs-hydrate marker), the router's
|
|
623
|
+
// data-wj-serialized stamp (applySwap marks every host in a parsed
|
|
624
|
+
// doc, covering a snapshot restore whose serialized shape has no
|
|
625
|
+
// projected slot to detect), or the structural detector (own
|
|
626
|
+
// data-webjs-light[data-projection] slots, attributes only the
|
|
627
|
+
// renderer / SSR stamps). Adopt-not-capture: capturing restored
|
|
628
|
+
// post-hydration HTML would hoover the rendered tree into the
|
|
629
|
+
// record and duplicate content on the next render (the #1006
|
|
630
|
+
// shape, on the restore path).
|
|
631
|
+
this.removeAttribute('data-wj-serialized');
|
|
632
|
+
ensureSlotState(this);
|
|
633
|
+
adoptSSRAssignments(this);
|
|
634
|
+
} else {
|
|
635
|
+
// (c) First mount with author-written children. Capture them.
|
|
636
|
+
captureAuthoredChildren(this);
|
|
637
|
+
}
|
|
638
|
+
// Install native-write interception AFTER capture (capture uses the
|
|
639
|
+
// host's still-native methods), then arm the sensors. Together they make
|
|
640
|
+
// appendChild / insertBefore / removeChild / innerHTML / slot= flips on a
|
|
641
|
+
// mounted light host drive the slot record, restoring full shadow-DOM
|
|
642
|
+
// parity through the standard DOM API. Interception installs once;
|
|
643
|
+
// sensors are armed on every connect and torn down on disconnect.
|
|
644
|
+
installSlotInterception(this);
|
|
645
|
+
installSlotSensors(this);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Notify all controllers that the host is connected.
|
|
649
|
+
for (const c of this.__controllers) {
|
|
650
|
+
if (c.hostConnected) c.hostConnected();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// First render is deferred to a microtask, matching lit's
|
|
654
|
+
// performUpdate scheduling. The user's connectedCallback override
|
|
655
|
+
// body runs synchronously to completion BEFORE the first render,
|
|
656
|
+
// so subclass setup that needs to inspect attributes / state / etc.
|
|
657
|
+
// observes consistent timing whether SSR-hydrating or first mount.
|
|
658
|
+
// Render-root setup (shadow attachment, light-DOM SSR adoption,
|
|
659
|
+
// controller hostConnected, _connected=true) all stay synchronous
|
|
660
|
+
// above; only the template commit + post-commit slot observers
|
|
661
|
+
// defer. Authoring contract: post-render DOM setup goes in
|
|
662
|
+
// firstUpdated(), NOT connectedCallback().
|
|
663
|
+
//
|
|
664
|
+
// The scheduling matches `_scheduleUpdate`'s wrapper so lifecycle
|
|
665
|
+
// throws are surfaced as console errors instead of bricking the
|
|
666
|
+
// element.
|
|
667
|
+
if (this._updateResolve === null) {
|
|
668
|
+
this._updatePromise = new Promise((resolve) => {
|
|
669
|
+
this._updateResolve = resolve;
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
this._scheduled = true;
|
|
673
|
+
queueMicrotask(() => {
|
|
674
|
+
this._scheduled = false;
|
|
675
|
+
try {
|
|
676
|
+
this._performRender();
|
|
677
|
+
} catch (err) {
|
|
678
|
+
console.error(`[webjs] lifecycle hook threw during initial render:`, err);
|
|
679
|
+
}
|
|
680
|
+
// The renderer's slot parts place the record content as part of the
|
|
681
|
+
// commit; native DOM writes on the host (appendChild, slot= flips,
|
|
682
|
+
// innerHTML) drive the record live through the interception + sensors
|
|
683
|
+
// installed on connect. There is no WebJs-specific slot API.
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* True when this host's first child is the framework's hydration
|
|
689
|
+
* marker, meaning the SSR pipeline already rendered the template's
|
|
690
|
+
* shape (including <slot data-webjs-light> elements with their
|
|
691
|
+
* projected children inside) and the client should bind events
|
|
692
|
+
* without re-creating DOM. Sets __hydratedAtActivate as a side
|
|
693
|
+
* effect so the post-render path picks up the SSR assignment table.
|
|
694
|
+
*
|
|
695
|
+
* @returns {boolean}
|
|
696
|
+
* @private
|
|
697
|
+
*/
|
|
698
|
+
__isHydrating() {
|
|
699
|
+
const first = this.firstChild;
|
|
700
|
+
const isHydrate =
|
|
701
|
+
first != null &&
|
|
702
|
+
first.nodeType === 8 &&
|
|
703
|
+
/** @type {Comment} */ (first).data === 'webjs-hydrate';
|
|
704
|
+
if (isHydrate) this.__hydratedAtActivate = true;
|
|
705
|
+
return isHydrate;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Called when the element is removed from the DOM.
|
|
711
|
+
*
|
|
712
|
+
* Notifies all attached {@link ReactiveController}s so they can clean up
|
|
713
|
+
* subscriptions, timers, and observers. If you override this in a
|
|
714
|
+
* subclass, always call `super.disconnectedCallback()`.
|
|
715
|
+
*/
|
|
716
|
+
disconnectedCallback() {
|
|
717
|
+
this._connected = false;
|
|
718
|
+
// Clean up the hydration observer if the element is removed before
|
|
719
|
+
// it became visible.
|
|
720
|
+
if (this.__hydrationObserver) {
|
|
721
|
+
this.__hydrationObserver.disconnect();
|
|
722
|
+
this.__hydrationObserver = null;
|
|
723
|
+
}
|
|
724
|
+
// Tear down the slot sensors, processing any queued records first (a bare
|
|
725
|
+
// disconnect() drops them). The per-host slot record + interception are
|
|
726
|
+
// preserved so a reconnection picks up where it left off (sensors re-arm in
|
|
727
|
+
// connectedCallback).
|
|
728
|
+
teardownSlotSensors(this);
|
|
729
|
+
// Dispose the signal watcher so dependency edges drop. Without
|
|
730
|
+
// this the element holds references to module-scope signals
|
|
731
|
+
// (and vice versa) forever.
|
|
732
|
+
if (this.__signalWatcher) {
|
|
733
|
+
this.__signalWatcher.dispose();
|
|
734
|
+
this.__signalWatcher = undefined;
|
|
735
|
+
}
|
|
736
|
+
for (const c of this.__controllers) {
|
|
737
|
+
if (c.hostDisconnected) c.hostDisconnected();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* @param {string} name Kebab-cased attribute name
|
|
743
|
+
* @param {string|null} _old
|
|
744
|
+
* @param {string|null} value
|
|
745
|
+
*/
|
|
746
|
+
attributeChangedCallback(name, _old, value) {
|
|
747
|
+
// When we are reflecting a property back to an attribute, ignore the
|
|
748
|
+
// resulting attributeChangedCallback to avoid infinite loops.
|
|
749
|
+
if (this.__reflectingAttribute) return;
|
|
750
|
+
|
|
751
|
+
// One name resolver for both sides (#1341). The browser only calls this for
|
|
752
|
+
// a name in `observedAttributes`, and it has already lowercased that name
|
|
753
|
+
// while parsing, so it passes straight through; the SSR caller lowercases
|
|
754
|
+
// its source name first. `resolveAttributeProperty` also normalises the
|
|
755
|
+
// declaration, so a bare-constructor shorthand (`count: Number`) and the
|
|
756
|
+
// long form (`{ type: Number, … }`) both arrive here as `{ type, … }`.
|
|
757
|
+
const resolved = resolveAttributeProperty(this.constructor, name);
|
|
758
|
+
if (resolved === undefined) return;
|
|
759
|
+
const { propName, def } = resolved;
|
|
760
|
+
|
|
761
|
+
// One reader for both sides, in `attribute-reader.js`: this and
|
|
762
|
+
// `applyAttrsToInstance` in `render-server.js` call the same function, so
|
|
763
|
+
// precedence and every fallback are shared rather than mirrored (#1340).
|
|
764
|
+
// It takes an already-decoded value, which the DOM has done here and
|
|
765
|
+
// `decodeAttrEntities` does at the SSR call site (#1341).
|
|
766
|
+
const v = readAttributeValue(def, value);
|
|
767
|
+
|
|
768
|
+
if (this[propName] !== v) {
|
|
769
|
+
this[propName] = v;
|
|
770
|
+
if (this._connected) this.requestUpdate();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Schedule a re-render. Optionally record a property change in
|
|
776
|
+
* `changedProperties` so hooks can branch on what changed.
|
|
777
|
+
*
|
|
778
|
+
* @param {string} [name] Property name that changed
|
|
779
|
+
* @param {unknown} [oldValue] Previous value of the property
|
|
780
|
+
*/
|
|
781
|
+
requestUpdate(name, oldValue) {
|
|
782
|
+
if (name !== undefined && !this._changedProperties.has(name)) {
|
|
783
|
+
this._changedProperties.set(name, oldValue);
|
|
784
|
+
}
|
|
785
|
+
this._scheduleUpdate();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Internal scheduler driven by reactive-property setters, the signal
|
|
790
|
+
* watcher, and explicit `requestUpdate()` calls. Coalesces multiple
|
|
791
|
+
* changes in the same tick into a single microtask render.
|
|
792
|
+
* Manages the `updateComplete` promise lifecycle. Short-circuits when
|
|
793
|
+
* the component is mid-update (assignments during `willUpdate` / `update`
|
|
794
|
+
* fold into the current cycle's `changedProperties` Map).
|
|
795
|
+
* @private
|
|
796
|
+
*/
|
|
797
|
+
_scheduleUpdate() {
|
|
798
|
+
if (this._updateResolve === null) {
|
|
799
|
+
this._updatePromise = new Promise((resolve) => {
|
|
800
|
+
this._updateResolve = resolve;
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
if (this._isUpdating) return;
|
|
804
|
+
if (this._scheduled || !this._connected) return;
|
|
805
|
+
this._scheduled = true;
|
|
806
|
+
queueMicrotask(() => {
|
|
807
|
+
this._scheduled = false;
|
|
808
|
+
try {
|
|
809
|
+
this._performRender();
|
|
810
|
+
} catch (err) {
|
|
811
|
+
// _performRender wraps the update phase in try/finally so this
|
|
812
|
+
// catches throws from shouldUpdate / willUpdate / hostUpdate /
|
|
813
|
+
// hostUpdated / firstUpdated / updated. The component is not
|
|
814
|
+
// left in a bad state (the finally blocks reset _isUpdating and
|
|
815
|
+
// resolve updateComplete). Surface the error for visibility.
|
|
816
|
+
console.error(`[webjs] lifecycle hook threw during update cycle:`, err);
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Core update cycle, lit-aligned:
|
|
823
|
+
* 1. Snapshot + clear `changedProperties`
|
|
824
|
+
* 2. `shouldUpdate(changedProperties)`. If false, resolve updateComplete and return.
|
|
825
|
+
* 3. `willUpdate(changedProperties)`. Safe to set properties without re-triggering.
|
|
826
|
+
* 4. Controllers' `hostUpdate()`
|
|
827
|
+
* 5. `update(changedProperties)`. Default impl calls `render()` + commits.
|
|
828
|
+
* Wrapped in error boundary that falls back to `renderError(error)`.
|
|
829
|
+
* 6. Controllers' `hostUpdated()`
|
|
830
|
+
* 7. `firstUpdated(changedProperties)` runs once, on the first render only
|
|
831
|
+
* 8. `updated(changedProperties)` runs after every commit
|
|
832
|
+
* 9. Resolve `updateComplete` promise
|
|
833
|
+
* @private
|
|
834
|
+
*/
|
|
835
|
+
_performRender() {
|
|
836
|
+
if (!this._renderRoot) return;
|
|
837
|
+
|
|
838
|
+
// Hold a stable reference to the current Map so all hooks see the same
|
|
839
|
+
// snapshot. During the update phase (steps 3-6) the Map is mutated in
|
|
840
|
+
// place when property setters fire, folding those changes into THIS
|
|
841
|
+
// cycle. The Map is replaced with a fresh empty one only after a
|
|
842
|
+
// successful commit so post-commit assignments queue the NEXT cycle.
|
|
843
|
+
// On `shouldUpdate=false` or hook errors, the Map is preserved so the
|
|
844
|
+
// accumulated changes survive into the next render.
|
|
845
|
+
const changedProperties = this._changedProperties;
|
|
846
|
+
|
|
847
|
+
// --- 1. Mark we're inside an update cycle ---
|
|
848
|
+
this._isUpdating = true;
|
|
849
|
+
let didCommit = false;
|
|
850
|
+
// Set when render() was async: the DOM commit lands when this settles,
|
|
851
|
+
// so the post-commit half of the cycle defers until then (#469).
|
|
852
|
+
let pendingCommit = null;
|
|
853
|
+
|
|
854
|
+
// --- 2-6. Update phase. Lifecycle-hook throws are logged and
|
|
855
|
+
// swallowed so the component is not left in a deadlocked state
|
|
856
|
+
// (`_isUpdating` stuck true, `updateComplete` never resolves).
|
|
857
|
+
try {
|
|
858
|
+
if (this.shouldUpdate(changedProperties)) {
|
|
859
|
+
// --- 3. willUpdate (may mutate properties; folds into this cycle) ---
|
|
860
|
+
this.willUpdate(changedProperties);
|
|
861
|
+
|
|
862
|
+
// --- 4. controllers' hostUpdate ---
|
|
863
|
+
for (const c of this.__controllers) {
|
|
864
|
+
if (c.hostUpdate) c.hostUpdate();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// --- 5. update + DOM commit (with render-error boundary) ---
|
|
868
|
+
// Stamp a fresh render token for THIS commit, sync or async. A later
|
|
869
|
+
// cycle that reaches here bumps it, so an in-flight async render whose
|
|
870
|
+
// token no longer matches drops its now-stale resolution. Stamping
|
|
871
|
+
// here (not inside _commitAsync) is what guards the async-superseded-
|
|
872
|
+
// by-sync case (#469); a shouldUpdate=false cycle never reaches here,
|
|
873
|
+
// so it does not invalidate an in-flight async render.
|
|
874
|
+
this.__renderToken = (this.__renderToken || 0) + 1;
|
|
875
|
+
// Abort the superseded render's in-flight action fetches (#492), start a
|
|
876
|
+
// fresh controller for this render, and bind it as the active signal so
|
|
877
|
+
// the RPC stub ties its fetches to this render. A superseded fetch's
|
|
878
|
+
// AbortError is dropped by the render-token guard in _commitAsync.
|
|
879
|
+
if (this.__renderAbort) this.__renderAbort.abort();
|
|
880
|
+
this.__renderAbort = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
881
|
+
setActiveActionSignal(this.__renderAbort ? this.__renderAbort.signal : null);
|
|
882
|
+
try {
|
|
883
|
+
const r = this.update(changedProperties);
|
|
884
|
+
if (r && typeof r.then === 'function') pendingCommit = r;
|
|
885
|
+
} catch (error) {
|
|
886
|
+
this._handleRenderError(/** @type {Error} */ (error));
|
|
887
|
+
} finally {
|
|
888
|
+
// The synchronous portion of render() has run; actions invoked there
|
|
889
|
+
// already captured the signal. Clear it so a later event handler is
|
|
890
|
+
// not bound to a stale render's controller.
|
|
891
|
+
setActiveActionSignal(null);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (!pendingCommit) {
|
|
895
|
+
// --- 6. controllers' hostUpdated (sync commit) ---
|
|
896
|
+
for (const c of this.__controllers) {
|
|
897
|
+
if (c.hostUpdated) c.hostUpdated();
|
|
898
|
+
}
|
|
899
|
+
didCommit = true;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// shouldUpdate=false: preserve _changedProperties so the next
|
|
903
|
+
// requestUpdate keeps accumulating on top of the entries that
|
|
904
|
+
// didn't render this cycle.
|
|
905
|
+
} catch (preCommitError) {
|
|
906
|
+
console.error(`[webjs] lifecycle hook threw during update phase:`, preCommitError);
|
|
907
|
+
} finally {
|
|
908
|
+
this._isUpdating = false;
|
|
909
|
+
// The snapshot is consumed once the commit is DISPATCHED. For an async
|
|
910
|
+
// render it resets here too, so a property change during the in-flight
|
|
911
|
+
// fetch starts a fresh cycle (whose render token supersedes this one).
|
|
912
|
+
if (didCommit || pendingCommit) {
|
|
913
|
+
this._changedProperties = new Map();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// --- Async render: finish the cycle when the pending commit settles. ---
|
|
918
|
+
if (pendingCommit) {
|
|
919
|
+
const token = this.__renderToken;
|
|
920
|
+
// Count this in-flight async commit so a non-committing cycle
|
|
921
|
+
// (shouldUpdate=false) running during the fetch does NOT resolve
|
|
922
|
+
// updateComplete early: the pending commit owns the resolution.
|
|
923
|
+
this.__pendingAsyncCommits = (this.__pendingAsyncCommits || 0) + 1;
|
|
924
|
+
const settle = () => {
|
|
925
|
+
// Always decrement first, even when superseded, so the in-flight
|
|
926
|
+
// count never leaks (a superseded cycle returns below without
|
|
927
|
+
// committing, but it is no longer pending).
|
|
928
|
+
this.__pendingAsyncCommits--;
|
|
929
|
+
// A newer render superseded this one; let the newer cycle finish.
|
|
930
|
+
if (token !== this.__renderToken) return;
|
|
931
|
+
// --- 6. controllers' hostUpdated (after the async commit) ---
|
|
932
|
+
for (const c of this.__controllers) {
|
|
933
|
+
if (c.hostUpdated) c.hostUpdated();
|
|
934
|
+
}
|
|
935
|
+
// --- 7-8 + updateComplete ---
|
|
936
|
+
this._postCommit(changedProperties);
|
|
937
|
+
};
|
|
938
|
+
// A rejection has to settle the cycle too, or the counter stays >= 1
|
|
939
|
+
// forever: that both wedges `updateComplete` for this instance and
|
|
940
|
+
// disables the `!this.__pendingAsyncCommits` escape below for every
|
|
941
|
+
// later non-committing cycle. `_commitAsync` is written not to reject,
|
|
942
|
+
// but it is not the only thenable that reaches here: `update()` is a
|
|
943
|
+
// documented override point and may return one, so the handler belongs
|
|
944
|
+
// at this site rather than only at the one implementation of it.
|
|
945
|
+
pendingCommit.then(settle, (error) => {
|
|
946
|
+
// The token check gates the DOM write, NOT the release. A superseded
|
|
947
|
+
// cycle rejecting late (a discarded fetch, or the #492 abort of its own
|
|
948
|
+
// in-flight action surfacing as an AbortError) must not commit its
|
|
949
|
+
// error state over the newer render that already replaced it, which is
|
|
950
|
+
// the same rule both handlers inside _commitAsync follow. `settle()`
|
|
951
|
+
// still runs either way: it does its own token check before touching
|
|
952
|
+
// anything, and the decrement it owns has to happen regardless or the
|
|
953
|
+
// counter wedges exactly as it did before this handler existed.
|
|
954
|
+
// `finally`, not a trailing statement: the release must survive
|
|
955
|
+
// anything the reporting path throws. `String(error)` is evaluated in
|
|
956
|
+
// the argument expression, OUTSIDE _handleRenderError's own try/catch,
|
|
957
|
+
// so a rejection reason that cannot be coerced (a null-prototype
|
|
958
|
+
// object, a revoked Proxy) threw a TypeError straight out of this
|
|
959
|
+
// handler and skipped the decrement, wedging the counter in exactly
|
|
960
|
+
// the way this handler exists to prevent.
|
|
961
|
+
try {
|
|
962
|
+
if (token === this.__renderToken) {
|
|
963
|
+
this._handleRenderError(error instanceof Error ? error : new Error(safeString(error)));
|
|
964
|
+
}
|
|
965
|
+
} finally {
|
|
966
|
+
settle();
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// --- 7-8. Post-commit hooks (sync path). Errors are caught so the
|
|
973
|
+
// updateComplete promise always resolves.
|
|
974
|
+
if (didCommit) {
|
|
975
|
+
this._postCommit(changedProperties);
|
|
976
|
+
} else if (!this.__pendingAsyncCommits) {
|
|
977
|
+
// A non-committing cycle resolves updateComplete only when no async
|
|
978
|
+
// commit is in flight; otherwise the pending commit resolves it once it
|
|
979
|
+
// lands, so `await el.updateComplete` never returns before that DOM.
|
|
980
|
+
this._resolveUpdate();
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Resolve the pending updateComplete promise. Value reflects whether any
|
|
986
|
+
* further updates were queued during the current cycle: `true` means the
|
|
987
|
+
* component has settled, `false` means another render will run.
|
|
988
|
+
* @private
|
|
989
|
+
*/
|
|
990
|
+
_resolveUpdate() {
|
|
991
|
+
if (this._updateResolve) {
|
|
992
|
+
const settled = this._changedProperties.size === 0;
|
|
993
|
+
this._updateResolve(settled);
|
|
994
|
+
this._updateResolve = null;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Run the pre-render half of the update cycle synchronously for SSR.
|
|
1000
|
+
*
|
|
1001
|
+
* The SSR walker constructs the instance, applies attributes/props, then
|
|
1002
|
+
* calls this BEFORE `render()`. It mirrors how lit drives the update
|
|
1003
|
+
* lifecycle server-side: `willUpdate` runs so derived state computed there
|
|
1004
|
+
* is correct in the SSR'd HTML, then reactive controllers' `hostUpdate`
|
|
1005
|
+
* runs, then reflect:true properties are written to attributes so they
|
|
1006
|
+
* appear in the serialized output. It deliberately does NOT run
|
|
1007
|
+
* `shouldUpdate` (SSR always produces the first paint, there is no prior
|
|
1008
|
+
* render to skip), `update` (the walker commits by calling `render`
|
|
1009
|
+
* itself), or the post-commit hooks `firstUpdated` / `updated` (browser-only
|
|
1010
|
+
* DOM work).
|
|
1011
|
+
*
|
|
1012
|
+
* `_isUpdating` is set so a property assignment inside `willUpdate` folds
|
|
1013
|
+
* into the current `changedProperties` snapshot instead of scheduling a
|
|
1014
|
+
* fresh cycle (the SSR scheduler short-circuits anyway, since the element
|
|
1015
|
+
* is not connected). Hook throws propagate to the walker's per-component
|
|
1016
|
+
* try/catch, which logs an actionable hint and skips DSD for that element,
|
|
1017
|
+
* the same as a throwing `render()`.
|
|
1018
|
+
*/
|
|
1019
|
+
performServerUpdate() {
|
|
1020
|
+
const changedProperties = this._changedProperties;
|
|
1021
|
+
this._isUpdating = true;
|
|
1022
|
+
try {
|
|
1023
|
+
this.willUpdate(changedProperties);
|
|
1024
|
+
for (const c of this.__controllers) {
|
|
1025
|
+
if (c.hostUpdate) c.hostUpdate();
|
|
1026
|
+
}
|
|
1027
|
+
this._reflectDeclaredAttributes();
|
|
1028
|
+
} finally {
|
|
1029
|
+
this._isUpdating = false;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Reflect every reflect:true (non-state) property to its attribute from its
|
|
1035
|
+
* CURRENT value, regardless of whether the value arrived via the setter.
|
|
1036
|
+
*
|
|
1037
|
+
* The property setter only reflects on a connected change, so a value set
|
|
1038
|
+
* in the constructor (a default) or in `willUpdate` is not reflected by the
|
|
1039
|
+
* setter alone. This syncs those: the SSR walker calls it in
|
|
1040
|
+
* `performServerUpdate` (writing into the server attribute shim, which the
|
|
1041
|
+
* walker reads back into the rendered HTML), and `_activate` calls it on
|
|
1042
|
+
* the client's first connected render. Running it on both sides is what
|
|
1043
|
+
* keeps a server-rendered element and a freshly-created client element
|
|
1044
|
+
* agree on their reflected attributes (matching lit, which reflects during
|
|
1045
|
+
* the first update). `_reflectAttribute`'s re-entrancy guard makes a
|
|
1046
|
+
* same-value reflect a no-op, so re-reflecting an attribute that already
|
|
1047
|
+
* arrived from SSR does not churn.
|
|
1048
|
+
* @private
|
|
1049
|
+
*/
|
|
1050
|
+
_reflectDeclaredAttributes() {
|
|
1051
|
+
const props = /** @type {any} */ (this.constructor).properties || {};
|
|
1052
|
+
for (const name of Object.keys(props)) {
|
|
1053
|
+
const decl = props[name];
|
|
1054
|
+
if (!decl || !decl.reflect || decl.state) continue;
|
|
1055
|
+
this._reflectAttribute(name, this[name], decl);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// ---------------------------------------------------------------------------
|
|
1060
|
+
// Lifecycle hooks: override in subclasses
|
|
1061
|
+
// ---------------------------------------------------------------------------
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Decide whether the component should render in response to a queued
|
|
1065
|
+
* update. Default implementation returns `true` (always render).
|
|
1066
|
+
*
|
|
1067
|
+
* **When to use:** override to skip an update when the relevant inputs
|
|
1068
|
+
* haven't changed, e.g. an expensive component that only depends on a
|
|
1069
|
+
* subset of its reactive properties.
|
|
1070
|
+
*
|
|
1071
|
+
* ```js
|
|
1072
|
+
* shouldUpdate(changedProperties) {
|
|
1073
|
+
* return changedProperties.has('itemCount') || changedProperties.has('mode');
|
|
1074
|
+
* }
|
|
1075
|
+
* ```
|
|
1076
|
+
*
|
|
1077
|
+
* @param {Map<string, unknown>} _changedProperties
|
|
1078
|
+
* @returns {boolean}
|
|
1079
|
+
*/
|
|
1080
|
+
shouldUpdate(_changedProperties) {
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Run immediately before `update()`. Safe to set reactive properties
|
|
1086
|
+
* here without triggering another update cycle: assignments made inside
|
|
1087
|
+
* `willUpdate` are folded into the current `changedProperties` snapshot
|
|
1088
|
+
* and rendered in the same pass.
|
|
1089
|
+
*
|
|
1090
|
+
* **When to use:** computing derived values from changed inputs before
|
|
1091
|
+
* `render()` reads them. Lit users typically migrate `shouldUpdate`
|
|
1092
|
+
* heuristics that mutate state into `willUpdate`.
|
|
1093
|
+
*
|
|
1094
|
+
* ```js
|
|
1095
|
+
* willUpdate(changedProperties) {
|
|
1096
|
+
* if (changedProperties.has('items')) {
|
|
1097
|
+
* this.totalCount = this.items.length;
|
|
1098
|
+
* }
|
|
1099
|
+
* }
|
|
1100
|
+
* ```
|
|
1101
|
+
*
|
|
1102
|
+
* @param {Map<string, unknown>} _changedProperties
|
|
1103
|
+
*/
|
|
1104
|
+
willUpdate(_changedProperties) {}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* The render-and-commit step. Default impl calls `render()` and commits
|
|
1108
|
+
* the returned `TemplateResult` to the render root.
|
|
1109
|
+
*
|
|
1110
|
+
* **When to use:** rarely. Override only when you need to wrap or
|
|
1111
|
+
* short-circuit the commit. Most users override `render()` instead.
|
|
1112
|
+
* If you do override, you MUST commit something to `this._renderRoot`
|
|
1113
|
+
* (or call `super.update(changedProperties)`).
|
|
1114
|
+
*
|
|
1115
|
+
* @param {Map<string, unknown>} _changedProperties
|
|
1116
|
+
*/
|
|
1117
|
+
update(_changedProperties) {
|
|
1118
|
+
// Track signal reads during render so the component re-renders when
|
|
1119
|
+
// any of them change. The watcher is lazy-allocated on first
|
|
1120
|
+
// render and disposed in disconnectedCallback. observe() clears
|
|
1121
|
+
// prior dep edges before fn(), so each render re-records its own
|
|
1122
|
+
// (possibly different) read set.
|
|
1123
|
+
if (!this.__signalWatcher) {
|
|
1124
|
+
this.__signalWatcher = new Signal.subtle.Watcher(() => {
|
|
1125
|
+
if (this._connected) this.requestUpdate();
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
let tpl;
|
|
1129
|
+
this.__signalWatcher.observe(() => { tpl = this.render(); });
|
|
1130
|
+
// Bare-await async render (#469): render() returned a promise. Use
|
|
1131
|
+
// stale-while-revalidate: keep the current DOM (the SSR first paint on
|
|
1132
|
+
// hydration, or the prior content on a re-fetch) visible until the new
|
|
1133
|
+
// template resolves, then commit it. Returns the pending promise so
|
|
1134
|
+
// _performRender defers the post-commit half of the cycle (hostUpdated,
|
|
1135
|
+
// firstUpdated/updated, updateComplete) until the real commit lands.
|
|
1136
|
+
// (Note: only signal reads BEFORE the first `await` are tracked; reads
|
|
1137
|
+
// after a suspension point do not establish reactive dependencies.)
|
|
1138
|
+
if (tpl && typeof (/** @type any */ (tpl).then) === 'function') {
|
|
1139
|
+
return this._commitAsync(/** @type {Promise<unknown>} */ (tpl));
|
|
1140
|
+
}
|
|
1141
|
+
clientRender(tpl, this._renderRoot);
|
|
1142
|
+
return undefined;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Commit a promise-returning render() with stale-while-revalidate
|
|
1147
|
+
* semantics (#469). The current DOM stays untouched until the template
|
|
1148
|
+
* resolves; on a client RE-FETCH an author-defined renderFallback()
|
|
1149
|
+
* optionally swaps in a loading state first. The cycle's render token
|
|
1150
|
+
* (stamped by _performRender before update() ran) drops a superseded
|
|
1151
|
+
* resolution so an out-of-order fetch never commits stale DOM, INCLUDING
|
|
1152
|
+
* when the superseding render is synchronous (a later cycle re-stamps the
|
|
1153
|
+
* token whether or not it is async). A rejection routes to the
|
|
1154
|
+
* renderError() boundary, isolated to this component. Returns a promise
|
|
1155
|
+
* that settles once the commit (or its error / fallback) has been applied.
|
|
1156
|
+
*
|
|
1157
|
+
* @param {Promise<unknown>} pending
|
|
1158
|
+
* @returns {Promise<void>}
|
|
1159
|
+
* @private
|
|
1160
|
+
*/
|
|
1161
|
+
_commitAsync(pending) {
|
|
1162
|
+
const token = this.__renderToken;
|
|
1163
|
+
// First paint (hydration): keep the SSR DOM, never a fallback, so
|
|
1164
|
+
// first-paint data stays visible with no skeleton flash. Re-fetch: an
|
|
1165
|
+
// author-defined renderFallback() overrides the stale-while-revalidate
|
|
1166
|
+
// default with an explicit loading state.
|
|
1167
|
+
if (this.__firstRendered && this._overridesRenderFallback()) {
|
|
1168
|
+
try {
|
|
1169
|
+
const fb = this.renderFallback();
|
|
1170
|
+
if (fb !== undefined) clientRender(fb, this._renderRoot);
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
console.error(`[webjs] renderFallback() threw:`, e);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return Promise.resolve(pending).then(
|
|
1176
|
+
(tpl) => {
|
|
1177
|
+
if (token !== this.__renderToken) return; // superseded by a newer render
|
|
1178
|
+
// The COMMIT throws too, not just the fetch, and a sibling rejection
|
|
1179
|
+
// handler cannot see it: `.then(onFulfil, onRejected)` never routes
|
|
1180
|
+
// onFulfil's own throw to onRejected. Left uncaught, the returned
|
|
1181
|
+
// promise rejected and _performRender's `.then` had no rejection
|
|
1182
|
+
// handler, so the error escaped as an unhandled rejection,
|
|
1183
|
+
// renderError() never ran, __pendingAsyncCommits never decremented
|
|
1184
|
+
// (wedging it >= 1 forever, which also disables the non-committing
|
|
1185
|
+
// cycle's _resolveUpdate escape), and updateComplete never settled.
|
|
1186
|
+
// The sync path routes an identical throw to the same boundary, so
|
|
1187
|
+
// this is what makes the two paths agree.
|
|
1188
|
+
try {
|
|
1189
|
+
clientRender(tpl, this._renderRoot);
|
|
1190
|
+
} catch (commitError) {
|
|
1191
|
+
this._handleRenderError(
|
|
1192
|
+
commitError instanceof Error ? commitError : new Error(String(commitError)),
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
},
|
|
1196
|
+
(error) => {
|
|
1197
|
+
if (token !== this.__renderToken) return;
|
|
1198
|
+
this._handleRenderError(error instanceof Error ? error : new Error(String(error)));
|
|
1199
|
+
},
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Whether the subclass overrides renderFallback() (vs the inert default).
|
|
1205
|
+
* @returns {boolean}
|
|
1206
|
+
* @private
|
|
1207
|
+
*/
|
|
1208
|
+
_overridesRenderFallback() {
|
|
1209
|
+
return this.renderFallback !== WebComponent.prototype.renderFallback;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Client-side render-error boundary, shared by the sync and async commit
|
|
1214
|
+
* paths. Logs, then commits renderError()'s output (if any) so one broken
|
|
1215
|
+
* component does not crash the page.
|
|
1216
|
+
* @param {Error} error
|
|
1217
|
+
* @private
|
|
1218
|
+
*/
|
|
1219
|
+
_handleRenderError(error) {
|
|
1220
|
+
console.error(
|
|
1221
|
+
`[webjs] render error in <${tagOf(/** @type any */ (this.constructor)) || this.tagName?.toLowerCase()}>:`,
|
|
1222
|
+
error,
|
|
1223
|
+
);
|
|
1224
|
+
try {
|
|
1225
|
+
const fallback = this.renderError(error);
|
|
1226
|
+
if (fallback !== undefined) clientRender(fallback, this._renderRoot);
|
|
1227
|
+
} catch (fallbackError) {
|
|
1228
|
+
console.error(`[webjs] renderError() also threw:`, fallbackError);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Post-commit half of the update cycle (steps 7-8 + updateComplete),
|
|
1234
|
+
* shared by the sync path and the async (post-resolution) path.
|
|
1235
|
+
* @param {Map<string, unknown>} changedProperties
|
|
1236
|
+
* @private
|
|
1237
|
+
*/
|
|
1238
|
+
_postCommit(changedProperties) {
|
|
1239
|
+
try {
|
|
1240
|
+
if (!this.__firstRendered) {
|
|
1241
|
+
this.__firstRendered = true;
|
|
1242
|
+
this.firstUpdated(changedProperties);
|
|
1243
|
+
}
|
|
1244
|
+
this.updated(changedProperties);
|
|
1245
|
+
} catch (postCommitError) {
|
|
1246
|
+
console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
|
|
1247
|
+
} finally {
|
|
1248
|
+
this._resolveUpdate();
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Called after every render commit (both the first and all subsequent
|
|
1254
|
+
* renders). Receives the `changedProperties` Map so you can branch on
|
|
1255
|
+
* what changed this cycle.
|
|
1256
|
+
*
|
|
1257
|
+
* **When to use:** post-render DOM work that depends on the new DOM,
|
|
1258
|
+
* triggered by specific property changes. This is the lit-aligned
|
|
1259
|
+
* replacement for ad-hoc `requestAnimationFrame` shims that components
|
|
1260
|
+
* historically used to defer DOM work after `render()`.
|
|
1261
|
+
*
|
|
1262
|
+
* ```js
|
|
1263
|
+
* updated(changedProperties) {
|
|
1264
|
+
* if (changedProperties.has('open') && this.open) {
|
|
1265
|
+
* this.querySelector('input')?.focus();
|
|
1266
|
+
* }
|
|
1267
|
+
* }
|
|
1268
|
+
* ```
|
|
1269
|
+
*
|
|
1270
|
+
* @param {Map<string, unknown>} _changedProperties
|
|
1271
|
+
*/
|
|
1272
|
+
updated(_changedProperties) {}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Called exactly once, after the component's very first render completes
|
|
1276
|
+
* and the DOM is live. Receives the same `changedProperties` Map that
|
|
1277
|
+
* `updated()` does for the first render; entries reflect the initial
|
|
1278
|
+
* values of reactive properties (old values are `undefined`).
|
|
1279
|
+
*
|
|
1280
|
+
* **When to use:** one-time post-render setup that requires DOM access.
|
|
1281
|
+
* Auto-focusing an input, measuring layout, initializing a third-party
|
|
1282
|
+
* library on a DOM node. `connectedCallback` fires before the first
|
|
1283
|
+
* render, so querying shadow children there yields nothing.
|
|
1284
|
+
*
|
|
1285
|
+
* ```js
|
|
1286
|
+
* firstUpdated(changedProperties) {
|
|
1287
|
+
* this.shadowRoot.querySelector('input')?.focus();
|
|
1288
|
+
* }
|
|
1289
|
+
* ```
|
|
1290
|
+
*
|
|
1291
|
+
* @param {Map<string, unknown>} _changedProperties
|
|
1292
|
+
*/
|
|
1293
|
+
firstUpdated(_changedProperties) {}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* A Promise that resolves after the next render commit. Resolves to
|
|
1297
|
+
* `true` when the component has settled (no further updates queued),
|
|
1298
|
+
* `false` if another render is already scheduled.
|
|
1299
|
+
*
|
|
1300
|
+
* **When to use:** awaiting a re-render in tests, or in user code that
|
|
1301
|
+
* needs to read the post-render DOM after triggering an update.
|
|
1302
|
+
*
|
|
1303
|
+
* ```js
|
|
1304
|
+
* el.count = 5;
|
|
1305
|
+
* await el.updateComplete;
|
|
1306
|
+
* // DOM now reflects count = 5
|
|
1307
|
+
* ```
|
|
1308
|
+
*
|
|
1309
|
+
* @returns {Promise<boolean>}
|
|
1310
|
+
*/
|
|
1311
|
+
get updateComplete() {
|
|
1312
|
+
return this.getUpdateComplete();
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Override point for `updateComplete`. Default returns the internal
|
|
1317
|
+
* update promise. Override to await additional async work that should
|
|
1318
|
+
* be considered part of the update cycle (e.g. lazy-loaded subcomponents).
|
|
1319
|
+
*
|
|
1320
|
+
* ```js
|
|
1321
|
+
* async getUpdateComplete() {
|
|
1322
|
+
* const result = await super.getUpdateComplete();
|
|
1323
|
+
* await this._chart?.updateComplete;
|
|
1324
|
+
* return result;
|
|
1325
|
+
* }
|
|
1326
|
+
* ```
|
|
1327
|
+
*
|
|
1328
|
+
* @returns {Promise<boolean>}
|
|
1329
|
+
*/
|
|
1330
|
+
getUpdateComplete() {
|
|
1331
|
+
return this._updatePromise;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ---------------------------------------------------------------------------
|
|
1335
|
+
// Reactive controllers
|
|
1336
|
+
// ---------------------------------------------------------------------------
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Register a {@link ReactiveController} with this component.
|
|
1340
|
+
*
|
|
1341
|
+
* **When to use:** call this from your controller's constructor (which
|
|
1342
|
+
* typically receives the host as its first argument) or from the
|
|
1343
|
+
* component's constructor / `connectedCallback`.
|
|
1344
|
+
*
|
|
1345
|
+
* **Why it exists:** controllers decouple reusable lifecycle behaviour
|
|
1346
|
+
* from the class hierarchy. Instead of extending a base class or using
|
|
1347
|
+
* a mixin, you compose controllers:
|
|
1348
|
+
*
|
|
1349
|
+
* ```js
|
|
1350
|
+
* class MouseController {
|
|
1351
|
+
* constructor(host) {
|
|
1352
|
+
* this.host = host;
|
|
1353
|
+
* host.addController(this);
|
|
1354
|
+
* }
|
|
1355
|
+
* hostConnected() { window.addEventListener('mousemove', this._onMove); }
|
|
1356
|
+
* hostDisconnected() { window.removeEventListener('mousemove', this._onMove); }
|
|
1357
|
+
* }
|
|
1358
|
+
* ```
|
|
1359
|
+
*
|
|
1360
|
+
* If the host is already connected when the controller is added, the
|
|
1361
|
+
* controller's `hostConnected()` is called immediately.
|
|
1362
|
+
*
|
|
1363
|
+
* @param {ReactiveController} controller
|
|
1364
|
+
*/
|
|
1365
|
+
addController(controller) {
|
|
1366
|
+
this.__controllers.add(controller);
|
|
1367
|
+
if (this._connected && controller.hostConnected) {
|
|
1368
|
+
controller.hostConnected();
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Unregister a previously added {@link ReactiveController}.
|
|
1374
|
+
*
|
|
1375
|
+
* **When to use:** call this when a controller's lifetime is shorter
|
|
1376
|
+
* than the component's: e.g. a controller that tracks a specific
|
|
1377
|
+
* resource and should be swapped out when the resource changes.
|
|
1378
|
+
*
|
|
1379
|
+
* The controller's `hostDisconnected()` is NOT called by `removeController`;
|
|
1380
|
+
* if cleanup is needed, call it yourself before removing.
|
|
1381
|
+
*
|
|
1382
|
+
* @param {ReactiveController} controller
|
|
1383
|
+
*/
|
|
1384
|
+
removeController(controller) {
|
|
1385
|
+
this.__controllers.delete(controller);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Override in subclasses to return a TemplateResult.
|
|
1390
|
+
* @returns {unknown}
|
|
1391
|
+
*/
|
|
1392
|
+
render() {
|
|
1393
|
+
return '';
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Called when `render()` throws an error on the client side.
|
|
1398
|
+
*
|
|
1399
|
+
* **When to override (AI hint):** Override this to show a fallback UI
|
|
1400
|
+
* when a component's render fails. Without this, the error is logged
|
|
1401
|
+
* and the component renders nothing. The default implementation returns
|
|
1402
|
+
* `undefined` (empty render).
|
|
1403
|
+
*
|
|
1404
|
+
* **Why it exists:** Client-side error boundary. Prevents one broken
|
|
1405
|
+
* component from crashing the entire page. Similar to React's
|
|
1406
|
+
* `componentDidCatch` / Error Boundaries.
|
|
1407
|
+
*
|
|
1408
|
+
* ```js
|
|
1409
|
+
* renderError(error) {
|
|
1410
|
+
* return html`<p style="color:red">Something went wrong: ${error.message}</p>`;
|
|
1411
|
+
* }
|
|
1412
|
+
* ```
|
|
1413
|
+
*
|
|
1414
|
+
* @param {Error} error The error thrown by render().
|
|
1415
|
+
* @returns {unknown} A TemplateResult fallback, or undefined for empty.
|
|
1416
|
+
*/
|
|
1417
|
+
renderError(error) {
|
|
1418
|
+
return undefined;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Optional loading UI for an async render() (#469), shown ONLY during a
|
|
1423
|
+
* client RE-FETCH (a prop / dependency change re-runs `async render()`),
|
|
1424
|
+
* NEVER on the first paint.
|
|
1425
|
+
*
|
|
1426
|
+
* **When to override (AI hint):** the client re-fetch default is
|
|
1427
|
+
* stale-while-revalidate (the component keeps showing its current content
|
|
1428
|
+
* until the new render resolves). Override `renderFallback()` only when you
|
|
1429
|
+
* want a loading state (skeleton / spinner) shown during the re-fetch
|
|
1430
|
+
* instead of the stale content, e.g. when stale data would mislead.
|
|
1431
|
+
*
|
|
1432
|
+
* **What it does NOT do:** it does not affect the first paint (SSR always
|
|
1433
|
+
* blocks and bakes real data in), and it does NOT create a server-streaming
|
|
1434
|
+
* boundary. To show a first-paint fallback for slow data, wrap the component
|
|
1435
|
+
* in `<webjs-suspense .fallback=${...}>` instead. It is a prop-aware method,
|
|
1436
|
+
* not a static field, so it can branch on the component's current state.
|
|
1437
|
+
*
|
|
1438
|
+
* ```js
|
|
1439
|
+
* renderFallback() { return html`<div class="skeleton h-24"></div>`; }
|
|
1440
|
+
* async render() { const u = await getUser(this.id); return html`<h3>${u.name}</h3>`; }
|
|
1441
|
+
* ```
|
|
1442
|
+
*
|
|
1443
|
+
* @returns {unknown} A TemplateResult loading state, or undefined to keep
|
|
1444
|
+
* the stale-while-revalidate default.
|
|
1445
|
+
*/
|
|
1446
|
+
renderFallback() {
|
|
1447
|
+
return undefined;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/** @param {string} s */
|
|
1452
|
+
function hyphenate(s) {
|
|
1453
|
+
return s.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
1454
|
+
}
|
|
1455
|
+
/** @param {string} s */
|
|
1456
|
+
function camelCase(s) {
|
|
1457
|
+
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Dual-role WebComponent class and factory.
|
|
1462
|
+
* Can be extended directly: `class X extends WebComponent`
|
|
1463
|
+
* Or called as a class factory: `class X extends WebComponent({ count: Number })`
|
|
1464
|
+
*
|
|
1465
|
+
* @param {Record<string, any>} [properties]
|
|
1466
|
+
* @returns {any}
|
|
1467
|
+
*/
|
|
1468
|
+
export function WebComponent(properties) {
|
|
1469
|
+
if (new.target) {
|
|
1470
|
+
return Reflect.construct(WebComponentBase, arguments, new.target);
|
|
1471
|
+
} else {
|
|
1472
|
+
return class extends WebComponentBase {
|
|
1473
|
+
static properties = properties;
|
|
1474
|
+
static [FACTORY_PROPS] = true;
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Ensure static inheritance and instanceof checks work
|
|
1480
|
+
Object.setPrototypeOf(WebComponent, WebComponentBase);
|
|
1481
|
+
WebComponent.prototype = WebComponentBase.prototype;
|