@webjsdev/core 0.7.64 → 0.7.65
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 +4 -4
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core-testing.js +21 -22
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +6 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +2 -2
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/attribute-reader.js +20 -0
- package/src/component/lifecycle.js +90 -48
- package/src/escape.js +12 -2
- package/src/form-action.js +7 -7
- package/src/optimistic.js +3 -3
- package/src/registry.d.ts +5 -0
- package/src/registry.js +35 -4
- package/src/render-client/parts.js +2 -2
- package/src/render-server/attrs.js +5 -4
- package/src/render-server/compile.js +419 -0
- package/src/render-server/dsd.js +11 -435
- package/src/render-server/errors.js +84 -0
- package/src/render-server/expand.js +319 -0
- package/src/render-server/html-scan.js +19 -358
- package/src/render-server/stream.js +11 -13
- package/src/render-server/template-renderer.js +13 -936
- package/src/render-server/walk.js +758 -0
- package/src/router-client/snapshot-cache.js +2 -12
- package/src/slot.js +1 -1
- package/src/render-server/slots.js +0 -213
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { stylesToString, isCSS } from '../css.js';
|
|
2
|
+
import { unsafeHTML } from '../directives.js';
|
|
3
|
+
import { escapeAttr } from '../escape.js';
|
|
4
|
+
import { defaultSSRErrorTemplate, browserMemberHint } from './errors.js';
|
|
5
|
+
import {
|
|
6
|
+
appendReflectedAttrs, applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
|
|
7
|
+
withHostMarker,
|
|
8
|
+
} from './attrs.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Custom-element expansion inside the op walk (#1646): what `injectDSD`
|
|
12
|
+
* used to do by rescanning the rendered string, moved here and driven by the
|
|
13
|
+
* walker's frames instead. `beginComponent` runs at a registered element's
|
|
14
|
+
* `>` (instantiate, seed and apply the source attributes, run the pre-render
|
|
15
|
+
* lifecycle, call `render()`, surface reflected attributes, mark a light
|
|
16
|
+
* host), and `finishComponent` runs at its end tag with the captured
|
|
17
|
+
* authored children, walking the instance's template with the light-DOM slot
|
|
18
|
+
* projection in scope or wrapping it in a Declarative Shadow DOM template.
|
|
19
|
+
* Every instance renders exactly once.
|
|
20
|
+
*
|
|
21
|
+
* The attribute plumbing stays in `attrs.js`, the light-DOM slot emission is
|
|
22
|
+
* `finishSlot` below (what `substituteSlotsInRender` emitted per slot), and
|
|
23
|
+
* `finishSuspense` is the `<webjs-suspense>` boundary (#471).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Stops at the first non-whitespace character; `trim()` copied the whole
|
|
27
|
+
// rendered template per component to answer the same question.
|
|
28
|
+
const NON_BLANK = /\S/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Instantiate and pre-render a registered custom element at its start tag.
|
|
32
|
+
* A throw anywhere here is caught into the frame (#469): the walker still
|
|
33
|
+
* consumes the authored children, and `finishComponent` emits the error state
|
|
34
|
+
* in the element's place while siblings render normally.
|
|
35
|
+
*
|
|
36
|
+
* @param {import('./walk.js').Walker} w
|
|
37
|
+
* @param {string} tag
|
|
38
|
+
* @param {any} Cls
|
|
39
|
+
* @param {string} opening the opening tag as written, `<tag attrs>`
|
|
40
|
+
* @param {string} attrs the attribute text between the tag name and `>`
|
|
41
|
+
* @returns {any} the frame; `pending` is set when `render()` returned a
|
|
42
|
+
* promise, and the caller awaits `settleRender` before using it
|
|
43
|
+
*/
|
|
44
|
+
export function beginComponent(w, tag, Cls, opening, attrs) {
|
|
45
|
+
if (w.ctx && w.ctx.usedComponents) w.ctx.usedComponents.add(tag);
|
|
46
|
+
const isShadow = Cls.shadow === true;
|
|
47
|
+
const frame = {
|
|
48
|
+
kind: 'component', tag, Cls, isShadow, instance: null, opening, tpl: undefined,
|
|
49
|
+
pending: /** @type {Promise<unknown> | null} */ (null), presentAttrNames: /** @type {Set<string> | null} */ (null),
|
|
50
|
+
error: /** @type {unknown} */ (null), outer: '',
|
|
51
|
+
// Light-DOM children partition: element depth, the open top-level
|
|
52
|
+
// segment, and the named-slot segments seen so far.
|
|
53
|
+
partition: !isShadow, depth: 0, seg: null, pendingSeg: null,
|
|
54
|
+
/** @type {{ start: number, end: number, slot: string }[] | null} */ segments: null,
|
|
55
|
+
closeText: '',
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
const instance = new Cls();
|
|
59
|
+
frame.instance = instance;
|
|
60
|
+
// Thread the ancestor chain (the enclosing custom-element instances) so
|
|
61
|
+
// the server element shim's closest() can resolve a parent at SSR. Set
|
|
62
|
+
// before performServerUpdate so a willUpdate() that reads a parent via
|
|
63
|
+
// closest() sees the chain.
|
|
64
|
+
instance.__ssrTag = tag;
|
|
65
|
+
instance.__ssrAncestors = w.ancestors.slice();
|
|
66
|
+
const attrMap = attrs ? parseAttrs(attrs) : {};
|
|
67
|
+
// Names already in the source opening tag (the data-webjs-prop-* bindings
|
|
68
|
+
// included, which the consume step below strips from attrMap but which
|
|
69
|
+
// stay in the emitted text). Reflected/added attributes are appended only
|
|
70
|
+
// when their name is NOT already here, so output stays byte-identical
|
|
71
|
+
// when nothing reflects.
|
|
72
|
+
const presentAttrNames = new Set();
|
|
73
|
+
for (const n in attrMap) presentAttrNames.add(n.toLowerCase());
|
|
74
|
+
// Decode `data-webjs-prop-*` first (rich-typed values emitted for
|
|
75
|
+
// `.prop=${val}` bindings in the parent template), then coerce the
|
|
76
|
+
// ordinary string attributes by declared type. Property bindings take
|
|
77
|
+
// priority on a name collision because they preserve the JS reference.
|
|
78
|
+
const propValues = consumePropAttrs(attrMap);
|
|
79
|
+
seedServerAttrs(instance, attrMap);
|
|
80
|
+
applyAttrsToInstance(instance, attrMap, Cls);
|
|
81
|
+
for (const k in propValues) instance[k] = propValues[k];
|
|
82
|
+
// The pre-render lifecycle (willUpdate, controllers' hostUpdate, then
|
|
83
|
+
// reflect:true reflection), matching lit's update cycle at SSR. Bare
|
|
84
|
+
// Base-extending kit components have no lifecycle, so it is guarded.
|
|
85
|
+
if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
|
|
86
|
+
const tpl = instance.render ? instance.render() : '';
|
|
87
|
+
frame.presentAttrNames = presentAttrNames;
|
|
88
|
+
if (tpl && typeof tpl.then === 'function') {
|
|
89
|
+
// An async render() is awaited by the walker through `settleRender`.
|
|
90
|
+
frame.pending = tpl;
|
|
91
|
+
return frame;
|
|
92
|
+
}
|
|
93
|
+
frame.tpl = tpl;
|
|
94
|
+
afterRender(frame);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
frame.error = e;
|
|
97
|
+
}
|
|
98
|
+
return frame;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Finish `beginComponent` for an async `render()`.
|
|
103
|
+
* @param {any} frame
|
|
104
|
+
*/
|
|
105
|
+
export async function settleRender(frame) {
|
|
106
|
+
try {
|
|
107
|
+
frame.tpl = await frame.pending;
|
|
108
|
+
afterRender(frame);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
frame.error = e;
|
|
111
|
+
}
|
|
112
|
+
frame.pending = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function afterRender(frame) {
|
|
116
|
+
// Surface attributes the component set up to and including render() that
|
|
117
|
+
// were not in the source tag (reflected props, an explicit setAttribute,
|
|
118
|
+
// a host mutation inside render()). Reading after render() captures all.
|
|
119
|
+
frame.opening = appendReflectedAttrs(frame.opening, frame.instance, frame.presentAttrNames);
|
|
120
|
+
// Mark LIGHT-DOM hosts for the `@layer webjs-host` display:block default.
|
|
121
|
+
// Shadow hosts are NOT marked: a document-level rule on the host beats the
|
|
122
|
+
// shadow tree's own `:host { display }`.
|
|
123
|
+
if (!frame.isShadow) frame.opening = withHostMarker(frame.opening);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Emit a finished component frame.
|
|
128
|
+
*
|
|
129
|
+
* @param {import('./walk.js').Walker} w
|
|
130
|
+
* @param {any} frame
|
|
131
|
+
* @param {string} children the captured authored children, expanded
|
|
132
|
+
* @returns {Promise<string>}
|
|
133
|
+
*/
|
|
134
|
+
export async function finishComponent(w, frame, children) {
|
|
135
|
+
const { tag, Cls, isShadow } = frame;
|
|
136
|
+
if (frame.error === null) {
|
|
137
|
+
try {
|
|
138
|
+
const inner = await renderOwnTemplate(w, frame, children);
|
|
139
|
+
if (isShadow) {
|
|
140
|
+
// Native <slot> stays as-is in the DSD template: the browser projects
|
|
141
|
+
// the host's light children natively. The authored children stay in
|
|
142
|
+
// place after the template, and the source end tag is kept.
|
|
143
|
+
return `${frame.opening}<template shadowrootmode="open">${styleString(Cls)}${inner}</template>${children}${frame.closeText}`;
|
|
144
|
+
}
|
|
145
|
+
if (!NON_BLANK.test(inner)) {
|
|
146
|
+
// An empty rendered template (a decorator component with no render(),
|
|
147
|
+
// or a render() returning an empty template): the host is a
|
|
148
|
+
// transparent wrapper and the authored children stay in place next to
|
|
149
|
+
// the hydration marker.
|
|
150
|
+
return `${frame.opening}<!--webjs-hydrate-->${children}${frame.closeText}`;
|
|
151
|
+
}
|
|
152
|
+
return `${frame.opening}<!--webjs-hydrate-->${inner}</${tag}>`;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
frame.error = e;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return errorState(w, frame);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Walk the instance's own template. For a light-DOM component the slot
|
|
162
|
+
* projection is in scope: the authored children partitioned by their
|
|
163
|
+
* top-level `slot=""` attribute (from the segments the walk recorded, no
|
|
164
|
+
* rescan), first-wins per name, owner stamped as this tag so a FORWARDED slot
|
|
165
|
+
* routes to the host that rendered it (#1023).
|
|
166
|
+
*/
|
|
167
|
+
async function renderOwnTemplate(w, frame, children) {
|
|
168
|
+
const owner = w.owner;
|
|
169
|
+
w.owner = frame.isShadow ? null : { tag: frame.tag, partition: partitionChildren(children, frame.segments), consumed: null };
|
|
170
|
+
try {
|
|
171
|
+
return await w.capture(() => w.value(frame.tpl));
|
|
172
|
+
} finally {
|
|
173
|
+
w.owner = owner;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The partition `partitionAuthoredBySlot` built by rescanning, built here from
|
|
179
|
+
* the recorded segments: text, comments and unslotted elements route to the
|
|
180
|
+
* default slot (null) in source order, each named segment to its name.
|
|
181
|
+
* @param {string} children
|
|
182
|
+
* @param {{ start: number, end: number, slot: string }[]} segments
|
|
183
|
+
* @returns {Map<string|null, string>}
|
|
184
|
+
*/
|
|
185
|
+
function partitionChildren(children, segments) {
|
|
186
|
+
/** @type {Map<string|null, string>} */
|
|
187
|
+
const groups = new Map();
|
|
188
|
+
if (!segments) {
|
|
189
|
+
if (children.length > 0) groups.set(null, children);
|
|
190
|
+
return groups;
|
|
191
|
+
}
|
|
192
|
+
let def = '';
|
|
193
|
+
let cursor = 0;
|
|
194
|
+
for (const seg of segments) {
|
|
195
|
+
def += children.slice(cursor, seg.start);
|
|
196
|
+
const existing = groups.get(seg.slot);
|
|
197
|
+
groups.set(seg.slot, existing === undefined ? children.slice(seg.start, seg.end) : existing + children.slice(seg.start, seg.end));
|
|
198
|
+
cursor = seg.end;
|
|
199
|
+
}
|
|
200
|
+
def += children.slice(cursor);
|
|
201
|
+
if (def.length > 0) groups.set(null, def);
|
|
202
|
+
return groups;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function styleString(Cls) {
|
|
206
|
+
const rawStyles = Cls.styles;
|
|
207
|
+
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
208
|
+
return stylesToString(styleList);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Per-component error isolation (#469). The element renders a
|
|
213
|
+
* component-scoped error state in its place (renderError() customises it;
|
|
214
|
+
* the default surfaces the message in dev and renders empty in prod, #483)
|
|
215
|
+
* plus a hydration marker so the client boundary can take over. A shadow
|
|
216
|
+
* component's error state rides a DSD template too, matching its success
|
|
217
|
+
* path.
|
|
218
|
+
*/
|
|
219
|
+
async function errorState(w, frame) {
|
|
220
|
+
const { tag, Cls, instance, isShadow } = frame;
|
|
221
|
+
const e = frame.error;
|
|
222
|
+
const hint = browserMemberHint(e);
|
|
223
|
+
if (hint) {
|
|
224
|
+
console.error(
|
|
225
|
+
`[webjs] SSR failed for <${tag}>: ${hint} It was touched in the component's constructor or render(), which run during SSR. Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes / props).`,
|
|
226
|
+
e,
|
|
227
|
+
);
|
|
228
|
+
} else {
|
|
229
|
+
console.error(`[webjs] SSR failed for <${tag}>:`, e);
|
|
230
|
+
}
|
|
231
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
232
|
+
let errorInner = '';
|
|
233
|
+
try {
|
|
234
|
+
let errTpl;
|
|
235
|
+
if (instance && typeof instance.renderError === 'function') errTpl = instance.renderError(err);
|
|
236
|
+
if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, w.dev);
|
|
237
|
+
errorInner = await w.capture(() => w.value(errTpl));
|
|
238
|
+
} catch (renderErrorThrew) {
|
|
239
|
+
console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
|
|
240
|
+
errorInner = '';
|
|
241
|
+
}
|
|
242
|
+
let opening = frame.opening;
|
|
243
|
+
if (!isShadow) opening = withHostMarker(opening);
|
|
244
|
+
if (isShadow) return `${opening}<template shadowrootmode="open">${styleString(Cls)}${errorInner}</template>`;
|
|
245
|
+
return `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Slots
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The `slot` attribute value of an element's attribute text, or null when
|
|
254
|
+
* absent. `slot=""` and the reserved alias `default` both mean the default
|
|
255
|
+
* slot (#1015), matching the client record.
|
|
256
|
+
* @param {string} attrsRaw
|
|
257
|
+
* @returns {string | null}
|
|
258
|
+
*/
|
|
259
|
+
export function extractSlotAttr(attrsRaw) {
|
|
260
|
+
const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
261
|
+
if (!m) return null;
|
|
262
|
+
const value = m[1] ?? m[2] ?? m[3] ?? '';
|
|
263
|
+
return value === '' || value === 'default' ? null : value;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Emit one `<slot>` of a light-DOM component's template as the framework
|
|
268
|
+
* slot: `data-webjs-light`, `data-projection="actual|fallback"`, the owner
|
|
269
|
+
* stamp, the name, and the projected children or the authored fallback.
|
|
270
|
+
* First-wins per name; a later same-named slot falls back regardless.
|
|
271
|
+
* @param {any} frame
|
|
272
|
+
* @param {string} fallback the slot's own captured children
|
|
273
|
+
* @returns {string}
|
|
274
|
+
*/
|
|
275
|
+
export function finishSlot(frame, fallback) {
|
|
276
|
+
const { attrsRaw, owner } = frame;
|
|
277
|
+
const ownerAttr = owner.tag ? ` data-wj-slot-owner="${escapeAttr(owner.tag)}"` : '';
|
|
278
|
+
let name = null;
|
|
279
|
+
let otherAttrs = '';
|
|
280
|
+
if (NON_BLANK.test(attrsRaw)) {
|
|
281
|
+
const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
282
|
+
name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
|
|
283
|
+
otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
|
|
284
|
+
}
|
|
285
|
+
const slotKey = name === 'default' || name === '' ? null : name;
|
|
286
|
+
const projected = owner.partition.get(slotKey);
|
|
287
|
+
const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
|
|
288
|
+
const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
|
|
289
|
+
if (projected !== undefined && !(owner.consumed && owner.consumed.has(slotKey))) {
|
|
290
|
+
(owner.consumed ??= new Set()).add(slotKey);
|
|
291
|
+
return `<slot data-webjs-light data-projection="actual"${ownerAttr}${nameAttr}${extraAttrs}>${projected}</slot>`;
|
|
292
|
+
}
|
|
293
|
+
return `<slot data-webjs-light data-projection="fallback"${ownerAttr}${nameAttr}${extraAttrs}>${fallback}</slot>`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// <webjs-suspense>
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A `<webjs-suspense>` boundary (#471). Streaming (a ctx is present): the
|
|
302
|
+
* placeholder carries the rendered `.fallback` and the boundary id, and the
|
|
303
|
+
* children, captured with their components unexpanded, are pushed to
|
|
304
|
+
* `ctx.pending` as raw markup for `streamSuspenseBoundaries` to resolve
|
|
305
|
+
* after the main flush (so every boundary's data fetches run concurrently).
|
|
306
|
+
* Blocking: the children rendered inline, the fallback dropped, the wrapper
|
|
307
|
+
* kept as an inert element.
|
|
308
|
+
* @param {import('./walk.js').Walker} w
|
|
309
|
+
* @param {any} frame
|
|
310
|
+
* @param {string} children
|
|
311
|
+
* @returns {Promise<string>}
|
|
312
|
+
*/
|
|
313
|
+
export async function finishSuspense(w, frame, children) {
|
|
314
|
+
if (w.ctx && frame.id) {
|
|
315
|
+
w.ctx.pending.push({ id: frame.id, promise: Promise.resolve(unsafeHTML(children)) });
|
|
316
|
+
return `<webjs-suspense id="${frame.id}">${frame.fallback}</webjs-suspense>`;
|
|
317
|
+
}
|
|
318
|
+
return `<webjs-suspense>${children}</webjs-suspense>`;
|
|
319
|
+
}
|