@webjsdev/core 0.7.63 → 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 +3 -3
- 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/binding-prefixes.js +20 -1
- 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/dom-differ.js +25 -2
- package/src/router-client/form-state.js +190 -0
- package/src/router-client/navigator.js +12 -0
- package/src/router-client/snapshot-cache.js +2 -12
- package/src/routes.d.ts +2 -0
- package/src/slot.js +1 -1
- package/src/render-server/slots.js +0 -213
package/src/render-server/dsd.js
CHANGED
|
@@ -1,445 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { stylesToString, isCSS } from '../css.js';
|
|
3
|
-
import { unsafeHTML } from '../directives.js';
|
|
4
|
-
import { render, defaultSSRErrorTemplate, browserMemberHint } from './template-renderer.js';
|
|
5
|
-
import { escapeRegex, findClosingTagInString, inertAt, inertRanges } from './html-scan.js';
|
|
6
|
-
import { decodeAttrEntities } from './text.js';
|
|
7
|
-
import {
|
|
8
|
-
appendReflectedAttrs, applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
|
|
9
|
-
withHostMarker,
|
|
10
|
-
} from './attrs.js';
|
|
11
|
-
import { partitionAuthoredBySlot, substituteSlotsInRender } from './slots.js';
|
|
1
|
+
import { expandHtml } from './walk.js';
|
|
12
2
|
|
|
13
3
|
/**
|
|
14
|
-
* Declarative Shadow DOM injection
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `<webjs-suspense>` pass that runs ahead of it.
|
|
22
|
-
*
|
|
23
|
-
* The dependency on `template-renderer.js` is one-way. That module reaches back
|
|
24
|
-
* only for `isRawtextTag` and `kebabCase`, and takes both from the leaves
|
|
25
|
-
* above, which is what keeps this directory acyclic.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Scan an HTML string for registered custom elements and inject
|
|
30
|
-
* Declarative Shadow DOM (`<template shadowrootmode="open">`).
|
|
31
|
-
* Awaits each component's render() so async components are fully resolved.
|
|
4
|
+
* Declarative Shadow DOM injection over an HTML STRING: the string-input path
|
|
5
|
+
* of the renderer. Since #1646 a page's custom elements are expanded inside
|
|
6
|
+
* the template walk itself (`walk.js` + `expand.js`), and this entry remains
|
|
7
|
+
* for markup that only exists as a string, which `streamSuspenseBoundaries`
|
|
8
|
+
* uses for a streamed boundary's children. It tokenizes the string through
|
|
9
|
+
* the same compiler the templates go through, so a tag inside a comment, a
|
|
10
|
+
* raw-text element or an attribute value is text here too (#1128).
|
|
32
11
|
*
|
|
33
12
|
* @param {string} html
|
|
34
|
-
* @param {SuspenseCtx} [ctx]
|
|
13
|
+
* @param {import('./stream.js').SuspenseCtx} [ctx]
|
|
35
14
|
* @param {any[]} [ancestors]
|
|
36
15
|
* @param {boolean} [dev] server dev flag, threaded to the per-component error
|
|
37
16
|
* template for prod-silence (#483); undefined falls back to NODE_ENV
|
|
38
17
|
* @returns {Promise<string>}
|
|
39
18
|
*/
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
// each becomes a fallback placeholder now, with its children pushed for
|
|
43
|
-
// out-of-order streaming; without a streaming context the children render
|
|
44
|
-
// inline (blocking). Run before the custom-element walk so a streamed
|
|
45
|
-
// boundary's children leave the main flow and are not double-processed.
|
|
46
|
-
html = await processSuspenseElements(html, ctx, ancestors, dev);
|
|
47
|
-
const tags = allTags();
|
|
48
|
-
if (!tags.length) return html;
|
|
49
|
-
// Sort longest tag name first so the regex alternation tries the most
|
|
50
|
-
// specific match before its prefixes. Combined with the (?=[\s>/])
|
|
51
|
-
// lookahead this prevents `my-card` from spuriously matching the prefix
|
|
52
|
-
// of `<my-card-2>` (or `slot-ssr-1` matching `<slot-ssr-14>`, etc).
|
|
53
|
-
// Attribute section is "anything that isn't `>`, with quoted values as a
|
|
54
|
-
// single unit" so slashes in URL-valued attrs (e.g. then="/dashboard") don't
|
|
55
|
-
// prevent the match. Non-greedy so self-closing `/>` still captures into the
|
|
56
|
-
// third group.
|
|
57
|
-
const sortedTags = [...tags].sort((a, b) => b.length - a.length);
|
|
58
|
-
const pattern = new RegExp(
|
|
59
|
-
`<(${sortedTags.map(escapeRegex).join('|')})(?=[\\s>/])((?:"[^"]*"|'[^']*'|[^>])*?)(/?)>`,
|
|
60
|
-
'g'
|
|
61
|
-
);
|
|
62
|
-
/** @type {{start:number, end:number, text:string}[]} */
|
|
63
|
-
const edits = [];
|
|
64
|
-
// A tag name inside a comment, a script, a style, RCDATA, or another tag's
|
|
65
|
-
// attribute value is text, not an element (#1128).
|
|
66
|
-
const inert = inertRanges(html);
|
|
67
|
-
const isInert = inertAt(inert);
|
|
68
|
-
for (const m of html.matchAll(pattern)) {
|
|
69
|
-
const [match, tag, attrs, selfClose] = m;
|
|
70
|
-
const Cls = lookup(tag);
|
|
71
|
-
if (!Cls) continue;
|
|
72
|
-
if (isInert(m.index)) continue;
|
|
73
|
-
// Track which custom elements actually appeared: used by SSR to emit
|
|
74
|
-
// `<link rel="modulepreload">` hints for their module URLs.
|
|
75
|
-
if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
|
|
76
|
-
let opening = selfClose ? `<${tag}${attrs}>` : match;
|
|
77
|
-
// Hoisted so the per-component error boundary (#469) can ask the failed
|
|
78
|
-
// instance for its renderError() output.
|
|
79
|
-
let instance = null;
|
|
80
|
-
try {
|
|
81
|
-
const isShadow = /** @type any */ (Cls).shadow === true;
|
|
82
|
-
instance = new /** @type any */ (Cls)();
|
|
83
|
-
// Thread the ancestor chain (the enclosing custom-element instances)
|
|
84
|
-
// so the server element shim's closest() can resolve a parent at SSR.
|
|
85
|
-
// Set before performServerUpdate so a willUpdate() that reads a parent
|
|
86
|
-
// via closest() sees the chain. Each child recursion below extends it.
|
|
87
|
-
instance.__ssrTag = tag;
|
|
88
|
-
instance.__ssrAncestors = ancestors;
|
|
89
|
-
const attrMap = parseAttrs(attrs);
|
|
90
|
-
// Decode `data-webjs-prop-*` attributes first (rich-typed values
|
|
91
|
-
// emitted for `.prop=${val}` bindings in the parent template),
|
|
92
|
-
// then coerce the ordinary string attributes by `static
|
|
93
|
-
// properties` type. Property bindings take priority on a name
|
|
94
|
-
// collision because they preserve the original JS reference.
|
|
95
|
-
const propValues = consumePropAttrs(attrMap);
|
|
96
|
-
// Names already present in the source opening tag (including the
|
|
97
|
-
// data-webjs-prop-* bindings, which were stripped from attrMap above
|
|
98
|
-
// but remain in the emitted `attrs` string). Reflected/added
|
|
99
|
-
// attributes are appended only when their name is NOT already here, so
|
|
100
|
-
// existing output stays byte-identical when nothing reflects.
|
|
101
|
-
const presentAttrNames = new Set(Object.keys(parseAttrs(attrs)).map((n) => n.toLowerCase()));
|
|
102
|
-
// Seed the server attribute shim so `this.getAttribute(...)` /
|
|
103
|
-
// `this.hasAttribute(...)` in willUpdate / render read the source
|
|
104
|
-
// attributes (a lit muscle-memory pattern) instead of reading empty.
|
|
105
|
-
seedServerAttrs(instance, attrMap);
|
|
106
|
-
applyAttrsToInstance(instance, attrMap, Cls);
|
|
107
|
-
for (const [k, v] of Object.entries(propValues)) instance[k] = v;
|
|
108
|
-
// Extract the authored inner HTML BEFORE the render (the injectDSD
|
|
109
|
-
// reorder): the source scan needs no render output, and hoisting it lets
|
|
110
|
-
// the light-DOM branch below project the authored children into the
|
|
111
|
-
// rendered slots. The shadow branch is a read-only peek (its authored
|
|
112
|
-
// children stay in place for native projection, and its edit keeps the
|
|
113
|
-
// old end).
|
|
114
|
-
let authoredInner = '';
|
|
115
|
-
let closeEnd = m.index + match.length;
|
|
116
|
-
if (!selfClose) {
|
|
117
|
-
const innerStart = m.index + match.length;
|
|
118
|
-
const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
|
|
119
|
-
if (closeIdx !== -1) {
|
|
120
|
-
authoredInner = html.slice(innerStart, closeIdx);
|
|
121
|
-
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
122
|
-
const tail = html.slice(closeIdx);
|
|
123
|
-
const closeMatch = closeRe.exec(tail);
|
|
124
|
-
const closeLen = closeMatch ? closeMatch[0].length : `</${tag}>`.length;
|
|
125
|
-
closeEnd = closeIdx + closeLen;
|
|
126
|
-
} else {
|
|
127
|
-
// Unclosed in source. Take rest of html as authored content
|
|
128
|
-
// and synthesize a closing tag on output.
|
|
129
|
-
authoredInner = html.slice(innerStart);
|
|
130
|
-
closeEnd = html.length;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
const partitioned = partitionAuthoredBySlot(authoredInner);
|
|
134
|
-
// Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
|
|
135
|
-
// then reflect reflect:true props) so derived state computed there is
|
|
136
|
-
// correct in the SSR'd HTML, matching how lit runs the update cycle at
|
|
137
|
-
// SSR. WebComponent instances expose performServerUpdate; bare
|
|
138
|
-
// Base-extending kit components (no lifecycle) do not, so it is guarded.
|
|
139
|
-
if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
|
|
140
|
-
let tpl = instance.render ? instance.render() : '';
|
|
141
|
-
if (tpl && typeof tpl.then === 'function') tpl = await tpl;
|
|
142
|
-
// Surface attributes the component set up to and including render()
|
|
143
|
-
// that were not already in the source tag: reflected reflect:true
|
|
144
|
-
// props, an explicit this.setAttribute in the constructor / willUpdate,
|
|
145
|
-
// or a host-attribute mutation inside render() itself (a light-DOM
|
|
146
|
-
// compound-component pattern, e.g. this.dataset.state / this.className /
|
|
147
|
-
// this.hidden on the host). Reading after render() captures all three.
|
|
148
|
-
// Appending keeps the original tag byte-identical when nothing changed.
|
|
149
|
-
opening = appendReflectedAttrs(opening, instance, presentAttrNames);
|
|
150
|
-
// Mark LIGHT-DOM component hosts so the framework default
|
|
151
|
-
// `@layer webjs-host { :where([data-wj-host]) { display: block } }`
|
|
152
|
-
// (injected once in the document head) applies at first paint. A custom
|
|
153
|
-
// element is `display:inline` by default, which collapses a component used
|
|
154
|
-
// as a block container (a board / card) until an author style intervenes.
|
|
155
|
-
// The low-priority `@layer` keeps it overridable by any author style,
|
|
156
|
-
// INCLUDING Tailwind's layered utilities (`class="flex"` wins). Emitted
|
|
157
|
-
// uniformly regardless of elision, so the elision on-vs-off differential is
|
|
158
|
-
// preserved.
|
|
159
|
-
//
|
|
160
|
-
// Shadow hosts are NOT marked: a document-level rule targeting the host
|
|
161
|
-
// beats the shadow tree's own `:host { display: … }` (the encapsulation-
|
|
162
|
-
// context criterion outranks both layer and specificity for normal
|
|
163
|
-
// declarations), so marking them would silently override the shadow
|
|
164
|
-
// author's `:host` display. Shadow components set their own host display
|
|
165
|
-
// via `:host` in `static styles` (the idiomatic mechanism), which the
|
|
166
|
-
// framework must not clobber.
|
|
167
|
-
if (!isShadow) opening = withHostMarker(opening);
|
|
168
|
-
// Render the template to HTML. injectDSD recurses on the result so
|
|
169
|
-
// nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
|
|
170
|
-
// get their own DSD pass.
|
|
171
|
-
// This is a SEPARATE render pass over one component's own template,
|
|
172
|
-
// driven by walking the already-emitted HTML, so it has no idea whether
|
|
173
|
-
// the host tag sits inside a `<form>`. It does not need to: a bound
|
|
174
|
-
// submitter carries its whole submission (#1307), so nothing here asks
|
|
175
|
-
// about an enclosing form.
|
|
176
|
-
const rawInner = await render(tpl, ctx);
|
|
177
|
-
|
|
178
|
-
if (isShadow) {
|
|
179
|
-
// Shadow DOM: native <slot> stays as-is in the DSD template. The
|
|
180
|
-
// browser handles projection from the host's light-DOM children
|
|
181
|
-
// into the shadow tree natively. No framework substitution here.
|
|
182
|
-
const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance], dev);
|
|
183
|
-
const rawStyles = /** @type any */ (Cls).styles;
|
|
184
|
-
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
185
|
-
const styleStr = stylesToString(styleList);
|
|
186
|
-
edits.push({
|
|
187
|
-
start: m.index,
|
|
188
|
-
end: m.index + match.length,
|
|
189
|
-
text: `${opening}<template shadowrootmode="open">${styleStr}${innerProcessed}</template>`,
|
|
190
|
-
});
|
|
191
|
-
} else {
|
|
192
|
-
// Light DOM. When the component has a non-empty rendered template,
|
|
193
|
-
// run the slot pipeline so behaviour matches shadow DOM: authored
|
|
194
|
-
// children are visible only where projected through <slot>; any
|
|
195
|
-
// child without a matching slot is dropped.
|
|
196
|
-
//
|
|
197
|
-
// When rendered template is empty (Base-extending decorator
|
|
198
|
-
// components that have no render() method, or render() that
|
|
199
|
-
// returns an empty template), the host acts as a transparent
|
|
200
|
-
// wrapper: authored children stay in place adjacent to the
|
|
201
|
-
// (empty) hydration marker. This preserves the kit's
|
|
202
|
-
// decorator-pattern components (those extending Base from the
|
|
203
|
-
// ui package's lib/utils.ts) without forcing a render() rewrite.
|
|
204
|
-
const renderedIsEmpty = rawInner.trim() === '';
|
|
205
|
-
if (renderedIsEmpty) {
|
|
206
|
-
edits.push({
|
|
207
|
-
start: m.index,
|
|
208
|
-
end: m.index + match.length,
|
|
209
|
-
text: `${opening}<!--webjs-hydrate-->`,
|
|
210
|
-
});
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
//
|
|
214
|
-
// The authored inner HTML + slot partition were extracted BEFORE
|
|
215
|
-
// the render (the #1015 reorder, see above), so here:
|
|
216
|
-
// 1. Substitute each <slot> in the rendered output with a
|
|
217
|
-
// framework-marked <slot data-webjs-light data-projection
|
|
218
|
-
// ="actual|fallback"> element carrying projection or
|
|
219
|
-
// fallback content per first-wins rule.
|
|
220
|
-
// 2. Recursively run injectDSD on the substituted output so
|
|
221
|
-
// nested custom elements (inside projected children) get
|
|
222
|
-
// their own DSD pass.
|
|
223
|
-
const innerWithSlots = substituteSlotsInRender(rawInner, partitioned, tag);
|
|
224
|
-
const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance], dev);
|
|
225
|
-
edits.push({
|
|
226
|
-
start: m.index,
|
|
227
|
-
end: closeEnd,
|
|
228
|
-
text: `${opening}<!--webjs-hydrate-->${innerProcessed}</${tag}>`,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
} catch (e) {
|
|
232
|
-
const hint = browserMemberHint(e);
|
|
233
|
-
if (hint) {
|
|
234
|
-
console.error(
|
|
235
|
-
`[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).`,
|
|
236
|
-
e,
|
|
237
|
-
);
|
|
238
|
-
} else {
|
|
239
|
-
console.error(`[webjs] SSR failed for <${tag}>:`, e);
|
|
240
|
-
}
|
|
241
|
-
// Per-component error isolation (#469). A render that throws (most
|
|
242
|
-
// commonly a rejected `await getData()` in an async render, but any
|
|
243
|
-
// render throw) is caught HERE, per component: the loop continues so
|
|
244
|
-
// siblings render normally, and this element renders a component-scoped
|
|
245
|
-
// error state instead of bubbling to the route error.js or leaving its
|
|
246
|
-
// raw, unprocessed children in the output. renderError() customizes the
|
|
247
|
-
// error UI; the default surfaces the message in dev and renders an empty
|
|
248
|
-
// (silent, isolated) element in prod so no internal detail leaks.
|
|
249
|
-
const err = e instanceof Error ? e : new Error(String(e));
|
|
250
|
-
let errorInner = '';
|
|
251
|
-
try {
|
|
252
|
-
let errTpl;
|
|
253
|
-
if (instance && typeof instance.renderError === 'function') {
|
|
254
|
-
errTpl = instance.renderError(err);
|
|
255
|
-
}
|
|
256
|
-
if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, dev);
|
|
257
|
-
errorInner = await render(errTpl, ctx);
|
|
258
|
-
if (errorInner.trim()) {
|
|
259
|
-
errorInner = await injectDSD(errorInner, ctx, instance ? [...ancestors, instance] : ancestors, dev);
|
|
260
|
-
}
|
|
261
|
-
} catch (renderErrorThrew) {
|
|
262
|
-
console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
|
|
263
|
-
errorInner = '';
|
|
264
|
-
}
|
|
265
|
-
// Replace the element (opening tag through its matching close) with the
|
|
266
|
-
// error state plus a hydration marker, so the client error boundary
|
|
267
|
-
// (component.js renderError) can take over on hydration.
|
|
268
|
-
let closeEnd = m.index + match.length;
|
|
269
|
-
if (!selfClose) {
|
|
270
|
-
const innerStart = m.index + match.length;
|
|
271
|
-
const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
|
|
272
|
-
if (closeIdx !== -1) {
|
|
273
|
-
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
274
|
-
const cm = closeRe.exec(html.slice(closeIdx));
|
|
275
|
-
closeEnd = closeIdx + (cm ? cm[0].length : `</${tag}>`.length);
|
|
276
|
-
} else {
|
|
277
|
-
closeEnd = html.length;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
// A shadow component renders into a shadow root on the client, so its
|
|
281
|
-
// SSR error state must ride a DSD template too (matching the success
|
|
282
|
-
// path), not land in light DOM. Otherwise the client renders the error
|
|
283
|
-
// into the shadow root while the light error box lingers underneath.
|
|
284
|
-
const isShadowErr = /** @type any */ (Cls).shadow === true;
|
|
285
|
-
// Mark the LIGHT host here too, so a component whose SSR render() throws
|
|
286
|
-
// paints its error state as display:block (not the inline default),
|
|
287
|
-
// matching the success path. When an `async render()` rejects, it throws
|
|
288
|
-
// before the success-path withHostMarker (above) ran, so `opening` is still
|
|
289
|
-
// unmarked; when a later template render throws, the success marker already
|
|
290
|
-
// ran and this call is a no-op (withHostMarker is idempotent). Shadow hosts
|
|
291
|
-
// stay unmarked (their :host must win).
|
|
292
|
-
if (!isShadowErr) opening = withHostMarker(opening);
|
|
293
|
-
let text;
|
|
294
|
-
if (isShadowErr) {
|
|
295
|
-
const rawStyles = /** @type any */ (Cls).styles;
|
|
296
|
-
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
297
|
-
const styleStr = stylesToString(styleList);
|
|
298
|
-
text = `${opening}<template shadowrootmode="open">${styleStr}${errorInner}</template>`;
|
|
299
|
-
} else {
|
|
300
|
-
text = `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
|
|
301
|
-
}
|
|
302
|
-
edits.push({ start: m.index, end: closeEnd, text });
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
if (!edits.length) return html;
|
|
306
|
-
|
|
307
|
-
// Drop edits whose range lives inside an earlier edit's range. This
|
|
308
|
-
// happens when an outer custom element with <slot> in its render takes
|
|
309
|
-
// an edit that spans its opening + closing tags (covering inner custom
|
|
310
|
-
// elements among authored children); the inner matches were enumerated
|
|
311
|
-
// independently against the original html, but those inner elements
|
|
312
|
-
// are processed by the recursive injectDSD call on innerWithSlots.
|
|
313
|
-
// Keeping both edits would double-process them and corrupt the output.
|
|
314
|
-
// A consequence: a nested instance's render() runs once per chain depth
|
|
315
|
-
// (the discarded top-level pass sees an empty ancestor chain, so its
|
|
316
|
-
// closest() reads null; the kept recursive pass has the real chain). The
|
|
317
|
-
// kept pass is the only output, and closest() is a read, so render() must
|
|
318
|
-
// stay pure at SSR (the standard SSR contract), not branch on side effects.
|
|
319
|
-
edits.sort((a, b) => a.start - b.start);
|
|
320
|
-
/** @type {{start:number, end:number, text:string}[]} */
|
|
321
|
-
const filtered = [];
|
|
322
|
-
let consumedTo = -1;
|
|
323
|
-
for (const e of edits) {
|
|
324
|
-
if (e.start >= consumedTo) {
|
|
325
|
-
filtered.push(e);
|
|
326
|
-
consumedTo = e.end;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
// Apply edits from last to first so indices stay stable.
|
|
330
|
-
let out = html;
|
|
331
|
-
for (let i = filtered.length - 1; i >= 0; i--) {
|
|
332
|
-
const { start, end, text } = filtered[i];
|
|
333
|
-
out = out.slice(0, start) + text + out.slice(end);
|
|
334
|
-
}
|
|
335
|
-
return out;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// ---------------------------------------------------------------------------
|
|
339
|
-
// Slot SSR helpers
|
|
340
|
-
// ---------------------------------------------------------------------------
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Resolve `<webjs-suspense>` boundaries in an HTML string (#471). For each
|
|
344
|
-
* top-level boundary (nested ones are handled by the recursive injectDSD that
|
|
345
|
-
* processes a boundary's streamed children):
|
|
346
|
-
*
|
|
347
|
-
* - Streaming (a SuspenseCtx is present): emit the boundary as
|
|
348
|
-
* `<webjs-suspense id="sN">FALLBACK</webjs-suspense>` and push the raw inner
|
|
349
|
-
* children to `ctx.pending` (wrapped in `unsafeHTML` so the streaming pass
|
|
350
|
-
* renders them as HTML, not escaped text, then runs injectDSD over them).
|
|
351
|
-
* `streamSuspenseBoundaries` later streams the resolved children as a
|
|
352
|
-
* `<template data-webjs-resolve="sN">` plus the swap script. Multiple
|
|
353
|
-
* boundaries resolve via `Promise.all`, so their data fetches run
|
|
354
|
-
* concurrently. The placeholder is the boundary's `.fallback`
|
|
355
|
-
* (carried as `data-webjs-fallback`); a boundary without one shows empty.
|
|
356
|
-
* - Blocking (no ctx): render the children inline now and drop the fallback,
|
|
357
|
-
* so a non-streaming `renderToString` returns the real content. The
|
|
358
|
-
* `<webjs-suspense>` wrapper stays as an inert inline element.
|
|
359
|
-
*
|
|
360
|
-
* @param {string} html
|
|
361
|
-
* @param {SuspenseCtx} [ctx]
|
|
362
|
-
* @param {any[]} [ancestors]
|
|
363
|
-
* @param {boolean} [dev] server dev flag for prod-silence of a throwing
|
|
364
|
-
* component in an inline (ctx-absent) boundary (#483)
|
|
365
|
-
* @returns {Promise<string>}
|
|
366
|
-
*/
|
|
367
|
-
export async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
368
|
-
if (html.indexOf('<webjs-suspense') === -1) return html;
|
|
369
|
-
const OPEN = /<webjs-suspense((?:"[^"]*"|'[^']*'|[^>])*?)>/i;
|
|
370
|
-
// A commented-out boundary is text, not an element (#1128). This scanner is
|
|
371
|
-
// the reason the comment fix cannot live in injectDSD alone: it runs FIRST
|
|
372
|
-
// and hands the boundary's children to a fresh injectDSD as a standalone
|
|
373
|
-
// string, which has no idea those bytes came from inside a comment. Under
|
|
374
|
-
// streaming it is worse than a stray render, because the children's data
|
|
375
|
-
// fetches run and the swap script targets an id that only exists inside a
|
|
376
|
-
// comment, so it can never resolve. Ranges are computed against the FULL
|
|
377
|
-
// input once, and `consumed` maps the shrinking `rest` back onto it.
|
|
378
|
-
const inert = inertRanges(html);
|
|
379
|
-
const isInert = inertAt(inert);
|
|
380
|
-
let consumed = 0;
|
|
381
|
-
let result = '';
|
|
382
|
-
let rest = html;
|
|
383
|
-
// Bounded loop: each iteration consumes at least the opening tag.
|
|
384
|
-
for (let guard = 0; guard < 10000; guard++) {
|
|
385
|
-
const m = OPEN.exec(rest);
|
|
386
|
-
if (!m) {
|
|
387
|
-
result += rest;
|
|
388
|
-
break;
|
|
389
|
-
}
|
|
390
|
-
if (isInert(consumed + m.index)) {
|
|
391
|
-
// Emit through the end of this match and keep scanning after it.
|
|
392
|
-
const skipTo = m.index + m[0].length;
|
|
393
|
-
result += rest.slice(0, skipTo);
|
|
394
|
-
rest = rest.slice(skipTo);
|
|
395
|
-
consumed += skipTo;
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
const openStart = m.index;
|
|
399
|
-
const openEnd = m.index + m[0].length;
|
|
400
|
-
result += rest.slice(0, openStart);
|
|
401
|
-
const attrs = m[1] || '';
|
|
402
|
-
const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
|
|
403
|
-
const fallbackHtml = fbMatch ? decodeAttrEntities(fbMatch[1]) : '';
|
|
404
|
-
|
|
405
|
-
// Pass the FULL-input ranges shifted into `rest` coordinates rather than
|
|
406
|
-
// letting the helper re-tokenize the suffix. `rest` can begin mid-comment
|
|
407
|
-
// or mid-raw-text after the skip path above, and a tokenizer restarted
|
|
408
|
-
// there is in the wrong state: a text-only opener named later in that same
|
|
409
|
-
// comment would read as a real element and mark everything to EOF inert,
|
|
410
|
-
// so the boundary's real close tag was skipped and the trailing markup
|
|
411
|
-
// folded into the boundary. The shifted view keeps the full-string truth,
|
|
412
|
-
// including a first range that starts before `rest` does.
|
|
413
|
-
const shifted = [];
|
|
414
|
-
for (const [s, e] of inert) {
|
|
415
|
-
if (e <= consumed) continue;
|
|
416
|
-
shifted.push([Math.max(0, s - consumed), e - consumed]);
|
|
417
|
-
}
|
|
418
|
-
const closeIdx = findClosingTagInString(rest, openEnd, 'webjs-suspense', shifted);
|
|
419
|
-
let inner;
|
|
420
|
-
let afterClose;
|
|
421
|
-
if (closeIdx === -1) {
|
|
422
|
-
inner = rest.slice(openEnd);
|
|
423
|
-
afterClose = '';
|
|
424
|
-
} else {
|
|
425
|
-
inner = rest.slice(openEnd, closeIdx);
|
|
426
|
-
const cm = /<\/webjs-suspense\s*>/i.exec(rest.slice(closeIdx));
|
|
427
|
-
afterClose = rest.slice(closeIdx + (cm ? cm[0].length : '</webjs-suspense>'.length));
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (ctx) {
|
|
431
|
-
const id = `s${ctx.nextId++}`;
|
|
432
|
-
// Raw children stream in later. unsafeHTML so the streaming pass emits
|
|
433
|
-
// the markup verbatim (then injectDSD runs over it, resolving the async
|
|
434
|
-
// components and any nested boundaries).
|
|
435
|
-
ctx.pending.push({ id, promise: Promise.resolve(unsafeHTML(inner)) });
|
|
436
|
-
result += `<webjs-suspense id="${id}">${fallbackHtml}</webjs-suspense>`;
|
|
437
|
-
} else {
|
|
438
|
-
const innerProcessed = await injectDSD(inner, ctx, ancestors, dev);
|
|
439
|
-
result += `<webjs-suspense>${innerProcessed}</webjs-suspense>`;
|
|
440
|
-
}
|
|
441
|
-
consumed += rest.length - afterClose.length;
|
|
442
|
-
rest = afterClose;
|
|
443
|
-
}
|
|
444
|
-
return result;
|
|
19
|
+
export function injectDSD(html, ctx, ancestors = [], dev) {
|
|
20
|
+
return expandHtml(html, ctx, { ancestors, dev });
|
|
445
21
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The SSR error helpers: the prod gate, the browser-member hint, and the
|
|
5
|
+
* default per-component error state (#469, #483). A leaf, so `expand.js`
|
|
6
|
+
* can take them without reaching back through the renderer entry.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** True in a production build (no dev error surfacing). */
|
|
10
|
+
export function isProd() {
|
|
11
|
+
return typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* If `e` is the recognisable failure of touching a browser-only API during
|
|
16
|
+
* SSR (a `ReferenceError` for a browser global, or a `TypeError` calling an
|
|
17
|
+
* HTMLElement method that does not exist on the bare server-side instance),
|
|
18
|
+
* return an actionable, member-naming hint; otherwise null.
|
|
19
|
+
* @param {unknown} e
|
|
20
|
+
* @returns {string | null}
|
|
21
|
+
*/
|
|
22
|
+
export function browserMemberHint(e) {
|
|
23
|
+
const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
|
|
24
|
+
// Match on a word boundary, NOT end-of-string: V8 (Node) ends the message at
|
|
25
|
+
// "is not defined" / "is not a function", but JSC (Bun) appends a detail
|
|
26
|
+
// clause (e.g. ". (In '({}).querySelector(\"p\")', '...' is undefined)"), so an
|
|
27
|
+
// anchored `$` would miss the Bun message and drop the actionable hint.
|
|
28
|
+
let m = /^(\w+) is not defined\b/.exec(msg);
|
|
29
|
+
if (e instanceof ReferenceError && m && SSR_BROWSER_GLOBALS.has(m[1])) {
|
|
30
|
+
return `\`${m[1]}\` is a browser-only global and is undefined during SSR.`;
|
|
31
|
+
}
|
|
32
|
+
m = /\.(\w+) is not a function\b/.exec(msg);
|
|
33
|
+
if (e instanceof TypeError && m && SSR_HTMLELEMENT_METHODS.has(m[1])) {
|
|
34
|
+
return `\`${m[1]}\` is an HTMLElement method that does not exist on the server-side component instance during SSR.`;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Browser-only names whose absence during SSR produces a recognisable error.
|
|
40
|
+
// Mirrors the `no-browser-globals-in-render` webjs check rule, which catches
|
|
41
|
+
// these at edit time; this turns the runtime SSR crash into the same guidance.
|
|
42
|
+
const SSR_BROWSER_GLOBALS = new Set([
|
|
43
|
+
'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
|
|
44
|
+
'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
|
|
45
|
+
'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Attribute methods (get/set/has/remove/toggleAttribute), the event methods
|
|
49
|
+
// (add/removeEventListener, dispatchEvent), and attachInternals are backed by
|
|
50
|
+
// the server-side element shim and work at SSR, so they are NOT listed here.
|
|
51
|
+
// What remains is the genuinely browser-only HTMLElement surface that still
|
|
52
|
+
// has no server stand-in and throws at SSR.
|
|
53
|
+
const SSR_HTMLELEMENT_METHODS = new Set([
|
|
54
|
+
'attachShadow', 'querySelector', 'querySelectorAll',
|
|
55
|
+
'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Default component-scoped error state for an async/sync render that threw
|
|
60
|
+
* during SSR, used when the component does not define renderError() (#469).
|
|
61
|
+
* Dev surfaces the tag + message loudly so the failure is obvious; prod
|
|
62
|
+
* renders an empty (silent, isolated) element so no internal detail leaks.
|
|
63
|
+
*
|
|
64
|
+
* The prod-silence signal is the SERVER's `dev` flag, threaded through the SSR
|
|
65
|
+
* render context (#483). WebJs keys prod on the CLI `dev` flag, not `NODE_ENV`,
|
|
66
|
+
* and `webjs start` does not export `NODE_ENV=production`, so a bare prod launch
|
|
67
|
+
* would otherwise leak the message. When `dev` is undefined (a context-free
|
|
68
|
+
* `renderToString` with no server signal, e.g. a bare unit test) it falls back
|
|
69
|
+
* to `isProd()` / `NODE_ENV`, preserving the prior behaviour for that path.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} tag
|
|
72
|
+
* @param {Error} err
|
|
73
|
+
* @param {boolean} [dev] server dev flag; undefined falls back to NODE_ENV
|
|
74
|
+
* @returns {unknown} a TemplateResult (dev) or '' (prod)
|
|
75
|
+
*/
|
|
76
|
+
export function defaultSSRErrorTemplate(tag, err, dev) {
|
|
77
|
+
const surface = dev === undefined ? !isProd() : !!dev;
|
|
78
|
+
if (!surface) return '';
|
|
79
|
+
const msg = err && err.message ? err.message : String(err);
|
|
80
|
+
return html`<div data-webjs-error="${tag}" style="border:1px solid #f5c2c7;background:#f8d7da;color:#842029;padding:8px 12px;border-radius:6px;font:13px/1.4 system-ui,sans-serif">
|
|
81
|
+
<strong><${tag}> failed to render</strong>
|
|
82
|
+
<div style="margin-top:4px;white-space:pre-wrap">${msg}</div>
|
|
83
|
+
</div>`;
|
|
84
|
+
}
|