@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,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
|
+
}
|