@webjsdev/core 0.7.49 → 0.7.51

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.
Files changed (61) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +21 -21
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +1 -1
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/attribute-reader.js +178 -0
  12. package/src/component/lifecycle.js +1481 -0
  13. package/src/component/reactive.js +171 -0
  14. package/src/component/server-element.js +248 -0
  15. package/src/component.d.ts +10 -0
  16. package/src/component.js +2 -1931
  17. package/src/html-entities.js +2179 -0
  18. package/src/render-client/parts.js +2007 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2939
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +923 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2334
  30. package/src/router-client/anchors.js +44 -0
  31. package/src/router-client/boundaries.js +221 -0
  32. package/src/router-client/constants.js +187 -0
  33. package/src/router-client/diagnostics.js +377 -0
  34. package/src/router-client/dom-differ.js +709 -0
  35. package/src/router-client/dom-parse.js +150 -0
  36. package/src/router-client/events.js +139 -0
  37. package/src/router-client/fetch-apply.js +387 -0
  38. package/src/router-client/form-encoder.js +135 -0
  39. package/src/router-client/frames.js +329 -0
  40. package/src/router-client/head-merge.js +344 -0
  41. package/src/router-client/nav-error.js +105 -0
  42. package/src/router-client/navigator.js +781 -0
  43. package/src/router-client/prefetch.js +824 -0
  44. package/src/router-client/scroll.js +259 -0
  45. package/src/router-client/snapshot-cache.js +97 -0
  46. package/src/router-client/state.js +92 -0
  47. package/src/router-client/stream.js +206 -0
  48. package/src/router-client/swap.js +451 -0
  49. package/src/router-client/upgrade.js +261 -0
  50. package/src/router-client/view-transition.js +223 -0
  51. package/src/router-client.d.ts +10 -1
  52. package/src/router-client.js +75 -5345
  53. package/src/slot/assignment.js +304 -0
  54. package/src/slot/interception.js +558 -0
  55. package/src/slot/polyfills.js +365 -0
  56. package/src/slot/project.js +432 -0
  57. package/src/slot/sensors.js +303 -0
  58. package/src/slot/state.js +245 -0
  59. package/src/slot/symbols.js +85 -0
  60. package/src/slot.js +7 -2199
  61. package/src/webjs-config.d.ts +13 -2
@@ -0,0 +1,160 @@
1
+ import { escapeAttr } from '../escape.js';
2
+ import { readAttributeValue, resolveAttributeProperty } from '../attribute-reader.js';
3
+ import { parse } from '../serialize.js';
4
+ import { camelCase, decodeAttrEntities } from './text.js';
5
+
6
+ /**
7
+ * Applying source attributes to a component instance during SSR, and writing
8
+ * the instance's own attributes back onto its opening tag.
9
+ *
10
+ * The instance-facing half of what `dsd.js` used to hold. It reaches the
11
+ * component through the standard `getAttribute` / `setAttribute` API, so it
12
+ * works against both the server element shim and a real `HTMLElement`.
13
+ */
14
+
15
+ /**
16
+ * Minimal attribute string parser.
17
+ * @param {string} attrStr
18
+ * @returns {Record<string,string>}
19
+ */
20
+ export function parseAttrs(attrStr) {
21
+ /** @type {Record<string,string>} */
22
+ const out = {};
23
+ const re = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
24
+ let m;
25
+ while ((m = re.exec(attrStr)) !== null) {
26
+ out[m[1]] = m[2] ?? m[3] ?? m[4] ?? '';
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Seed the element's attributes from the source opening tag so reads like
33
+ * `this.getAttribute(name)` / `this.hasAttribute(name)` inside willUpdate /
34
+ * render return the real value during SSR. Goes through `setAttribute`, which
35
+ * both the server element shim (Node SSR) and a real `HTMLElement`
36
+ * (renderToString called in a browser, e.g. tests) implement, so the path
37
+ * does not depend on the shim's internal store. A bare Base-extending kit
38
+ * component without `setAttribute` is skipped.
39
+ *
40
+ * @param {any} instance
41
+ * @param {Record<string,string>} attrs parsed source attributes (data-webjs-prop-* already removed)
42
+ */
43
+ export function seedServerAttrs(instance, attrs) {
44
+ if (!instance || typeof instance.setAttribute !== 'function') return;
45
+ for (const [name, raw] of Object.entries(attrs)) {
46
+ instance.setAttribute(name, decodeAttrEntities(raw));
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Add the component host marker (`data-wj-host`) to an opening tag, unless it
52
+ * is already present. Insert before the closing `>` the same way
53
+ * `appendReflectedAttrs` does. Idempotent so a re-processed tag is unchanged.
54
+ * @param {string} opening the element's opening tag, ending in `>`
55
+ * @returns {string}
56
+ */
57
+ export function withHostMarker(opening) {
58
+ if (/\sdata-wj-host(?=[\s>=])/i.test(opening)) return opening;
59
+ return `${opening.slice(0, -1)} data-wj-host>`;
60
+ }
61
+
62
+ /**
63
+ * Append attributes the component set before render (reflected reflect:true
64
+ * properties, or an explicit `this.setAttribute` in the constructor /
65
+ * willUpdate) to the element's opening tag, skipping any name already present
66
+ * in the source tag. Reads via the standard `getAttributeNames` /
67
+ * `getAttribute` API so it works whether the instance is the server shim or a
68
+ * real `HTMLElement`. Returns the opening tag unchanged when there is nothing
69
+ * to add, so existing SSR output stays byte-identical when no component
70
+ * reflects, which preserves the elision on-vs-off differential invariant.
71
+ *
72
+ * @param {string} opening the element's opening tag, ending in `>`
73
+ * @param {any} instance
74
+ * @param {Set<string>} presentAttrNames lowercased names already in the source tag
75
+ * @returns {string}
76
+ */
77
+ export function appendReflectedAttrs(opening, instance, presentAttrNames) {
78
+ if (!instance || typeof instance.getAttributeNames !== 'function') return opening;
79
+ let extra = '';
80
+ for (const rawName of instance.getAttributeNames()) {
81
+ const name = String(rawName).toLowerCase();
82
+ if (presentAttrNames.has(name)) continue;
83
+ const value = instance.getAttribute(rawName);
84
+ extra += value === '' ? ` ${name}` : ` ${name}="${escapeAttr(String(value))}"`;
85
+ }
86
+ if (!extra) return opening;
87
+ // Insert before the closing `>` (the opening tag is normalised to end in
88
+ // `>`; a self-closing source tag was already rewritten without the slash).
89
+ return `${opening.slice(0, -1)}${extra}>`;
90
+ }
91
+
92
+ /**
93
+ * Coerce attribute strings to typed properties on a component instance
94
+ * based on its static `properties` declaration.
95
+ */
96
+ export function applyAttrsToInstance(instance, attrs, Cls) {
97
+ for (const [sourceName, sourceValue] of Object.entries(attrs)) {
98
+ // The browser LOWERCASES every attribute name while parsing an HTML
99
+ // document, so `cfgData="x"` reaches the client reader as `cfgdata` and a
100
+ // camelCase name can never match anything in `observedAttributes`.
101
+ // Resolving the source case here made SSR read a name the platform cannot
102
+ // deliver, which is the divergence, not the fix (#1341).
103
+ const resolved = resolveAttributeProperty(Cls, sourceName.toLowerCase());
104
+ // An attribute mapping to no attribute-backed property is IGNORED, exactly
105
+ // as `attributeChangedCallback` ignores it and as lit's reader does. This
106
+ // used to assign it as an instance property (`instance[propName] = raw`),
107
+ // which no browser upgrade ever reproduces, and which on a real
108
+ // HTMLElement could mutate DOM state through `id` / `hidden` / `slot`.
109
+ // `seedServerAttrs` has already applied every source attribute properly,
110
+ // so nothing needs the copy (#1341).
111
+ if (resolved === undefined) continue;
112
+ const { propName, def } = resolved;
113
+ // A browser decodes every character reference BEFORE any reader sees the
114
+ // value, so decode once here, for every branch, and hand the shared reader
115
+ // an already-decoded string exactly as the DOM hands the client one. It
116
+ // used to be a `decode` argument the reader applied on the JSON and
117
+ // converter branches alone, which left a `String`-typed prop holding the
118
+ // raw source text while `getAttribute()` on the client returned the decoded
119
+ // one (#1341).
120
+ //
121
+ // One reader for both sides (#1340): `readAttributeValue` in
122
+ // `attribute-reader.js` is the same function `attributeChangedCallback` in
123
+ // `component.js` calls, so a custom `converter.fromAttribute` runs here
124
+ // too, ahead of type coercion, and the #1253 unparseable-JSON fallback is
125
+ // shared rather than mirrored.
126
+ //
127
+ // A converter that THROWS is not caught here. It lands in the
128
+ // per-component error isolation below, which is deliberate: an author who
129
+ // supplies a converter owns the read, the same rule `_reflectAttribute`
130
+ // states for `toAttribute`. See the comment on `readAttributeValue`.
131
+ instance[propName] = readAttributeValue(def, decodeAttrEntities(sourceValue));
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Decode `data-webjs-prop-<kebab>` attributes from a parsed attribute
137
+ * map, returning a map of camelCase property name to decoded value.
138
+ * Mutates `attrs` by deleting the consumed entries so they do not
139
+ * appear in the rendered output a second time.
140
+ *
141
+ * @param {Record<string,string>} attrs
142
+ * @returns {Record<string, unknown>}
143
+ */
144
+ export function consumePropAttrs(attrs) {
145
+ /** @type {Record<string, unknown>} */
146
+ const props = {};
147
+ for (const key of Object.keys(attrs)) {
148
+ if (!key.startsWith('data-webjs-prop-')) continue;
149
+ const propName = camelCase(key.slice('data-webjs-prop-'.length));
150
+ try {
151
+ props[propName] = parse(decodeAttrEntities(attrs[key]));
152
+ } catch {
153
+ // Malformed payload. Skip silently so the rest of the component
154
+ // can still render. The client-side hydration will also try and
155
+ // fail, which is fine: undefined-prop semantics.
156
+ }
157
+ delete attrs[key];
158
+ }
159
+ return props;
160
+ }
@@ -0,0 +1,445 @@
1
+ import { lookup, allTags } from '../registry.js';
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';
12
+
13
+ /**
14
+ * Declarative Shadow DOM injection: the SSR pass that walks rendered HTML for
15
+ * registered custom elements, instantiates each one, renders it, and splices
16
+ * the result back in.
17
+ *
18
+ * The string-scanning primitives live in `html-scan.js`, the attribute plumbing
19
+ * in `attrs.js`, the light-DOM slot projection in `slots.js`, and name-case /
20
+ * entity decoding in `text.js`. This module owns the walk itself and the
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.
32
+ *
33
+ * @param {string} html
34
+ * @param {SuspenseCtx} [ctx]
35
+ * @param {any[]} [ancestors]
36
+ * @param {boolean} [dev] server dev flag, threaded to the per-component error
37
+ * template for prod-silence (#483); undefined falls back to NODE_ENV
38
+ * @returns {Promise<string>}
39
+ */
40
+ export async function injectDSD(html, ctx, ancestors = [], dev) {
41
+ // Resolve <webjs-suspense> boundaries first (#471): in a streaming context
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;
445
+ }