@webjsdev/core 0.7.64 → 0.8.0

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 (58) 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 -22
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +6 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +6 -2
  8. package/index.d.ts +7 -3
  9. package/index.js +6 -2
  10. package/package.json +1 -1
  11. package/src/attribute-directives.d.ts +6 -0
  12. package/src/attribute-directives.js +49 -0
  13. package/src/attribute-reader.js +37 -3
  14. package/src/binding-prefixes.js +10 -41
  15. package/src/component/lifecycle.js +261 -101
  16. package/src/component/reactive.js +5 -4
  17. package/src/component.d.ts +11 -5
  18. package/src/directives.d.ts +2 -0
  19. package/src/directives.js +2 -0
  20. package/src/escape.js +12 -2
  21. package/src/form-action.js +7 -7
  22. package/src/html.d.ts +4 -0
  23. package/src/html.js +22 -1
  24. package/src/optimistic.js +3 -3
  25. package/src/registry.d.ts +5 -0
  26. package/src/registry.js +35 -4
  27. package/src/render-client/adopt-slots.js +245 -0
  28. package/src/render-client/parts.js +53 -19
  29. package/src/render-client/reconciler.js +28 -12
  30. package/src/render-client/template-compiler.js +19 -5
  31. package/src/render-client.d.ts +2 -1
  32. package/src/render-server/attrs.js +5 -4
  33. package/src/render-server/compile.js +433 -0
  34. package/src/render-server/dsd.js +11 -435
  35. package/src/render-server/errors.js +84 -0
  36. package/src/render-server/expand.js +346 -0
  37. package/src/render-server/html-scan.js +19 -358
  38. package/src/render-server/served-attrs.js +79 -0
  39. package/src/render-server/stream.js +11 -13
  40. package/src/render-server/template-renderer.js +13 -936
  41. package/src/render-server/walk.js +798 -0
  42. package/src/router-client/anchors.js +3 -1
  43. package/src/router-client/boundaries.js +15 -1
  44. package/src/router-client/constants.js +30 -0
  45. package/src/router-client/fetch-apply.js +21 -0
  46. package/src/router-client/frames.js +6 -3
  47. package/src/router-client/head-merge.js +58 -10
  48. package/src/router-client/navigator.js +4 -0
  49. package/src/router-client/owned-styles.js +372 -0
  50. package/src/router-client/prefetch.js +3 -0
  51. package/src/router-client/snapshot-cache.js +2 -12
  52. package/src/router-client/stylesheets.js +191 -0
  53. package/src/router-client/swap.js +49 -9
  54. package/src/sentinels.d.ts +2 -0
  55. package/src/sentinels.js +4 -0
  56. package/src/slot.js +1 -1
  57. package/src/webjs-config.d.ts +1 -1
  58. package/src/render-server/slots.js +0 -213
@@ -1,10 +1,13 @@
1
1
  /**
2
- * HTML string scanning primitives shared by the SSR passes.
2
+ * HTML element-class predicates shared by the SSR compiler and walker.
3
3
  *
4
- * Everything here is a pure function of its arguments: no component registry,
5
- * no renderer, no DOM. That is what keeps this module a leaf, which is what
6
- * lets `template-renderer.js` take `isRawtextTag` from here instead of from
7
- * `dsd.js`, which is what breaks the render-server import cycle.
4
+ * Everything here is a pure function of its argument: no component registry,
5
+ * no renderer, no DOM. That is what keeps this module a leaf.
6
+ *
7
+ * Until #1646 this module also held the output rescanners (`inertRanges`,
8
+ * `findClosingTagInString`, the per-tag regexes). The compiler now records the
9
+ * same contexts as ops in its one tokenizer pass, so nothing rescans rendered
10
+ * HTML and those scanners are gone.
8
11
  */
9
12
 
10
13
  const VOID_ELEMENTS = new Set([
@@ -18,350 +21,11 @@ export function isVoidElement(tag) {
18
21
  }
19
22
 
20
23
  /**
21
- * Index just past the end of the comment starting at `start`, or -1 if it is
22
- * unterminated. Shared so every scanner that has to decide where a comment
23
- * stops agrees, including on the spec short forms.
24
- *
25
- * @param {string} html
26
- * @param {number} start index of the `<` of `<!--`
27
- * @returns {number}
28
- */
29
- export function endOfComment(html, start) {
30
- let p = start + 4;
31
- // `<!-->` and `<!--->` are comments whose data is empty (spec short forms).
32
- if (html[p] === '>') return p + 1;
33
- if (html.startsWith('->', p)) return p + 2;
34
- while (p < html.length) {
35
- // `--!>` is the spec's "abrupt closing" form and closes just like `-->`.
36
- if (html.startsWith('-->', p)) return p + 3;
37
- if (html.startsWith('--!>', p)) return p + 4;
38
- p += 1;
39
- }
40
- return -1;
41
- }
42
-
43
- /**
44
- * Index of the `</script` that really closes a `<script>` whose content starts
45
- * at `from`, or -1 when unterminated (#1134).
46
- *
47
- * Script data is not plain raw text: once the content contains `<!--` followed
48
- * by `<script`, the tokenizer is in the script-data-double-escaped state, where
49
- * a `</script>` is TEXT (it only steps back to the escaped state) and the
50
- * element ends at the NEXT `</script>`. The legacy comment-wrapped inline
51
- * script that document.writes a script tag is the pattern that produces this.
52
- * Stopping at the first `</script>` there re-opened the original #1128 bug in
53
- * the one element the scanner most explicitly claims to handle.
54
- *
55
- * @param {string} html
56
- * @param {number} from index just past the opening tag's `>`
57
- * @returns {number}
58
- */
59
- export function endOfScriptContent(html, from) {
60
- const re = /<!--|-->|<\/script(?=[\s/>])|<script(?=[\s/>])/gi;
61
- re.lastIndex = from;
62
- let escaped = false;
63
- let dbl = false;
64
- let m;
65
- while ((m = re.exec(html)) !== null) {
66
- const t = m[0];
67
- if (t === '<!--') {
68
- // The token's own trailing `--` puts the tokenizer in a dash-dash state
69
- // REGARDLESS of what state it was in (`<!` are inert bytes in escaped and
70
- // double-escaped states too), and every dash-dash state exits straight
71
- // back to plain script data on `>`. So `<!-->`, `<!--->`, and any dash
72
- // run followed by `>` clear BOTH flags: entering fresh it cancels the
73
- // escape before it starts, and inside an escaped or double-escaped body
74
- // it is the exit a browser honours, after which the element ends at the
75
- // next `</script>`.
76
- let q = m.index + 4;
77
- while (html[q] === '-') q += 1;
78
- if (html[q] === '>') { escaped = false; dbl = false; re.lastIndex = q + 1; }
79
- else if (!escaped) escaped = true;
80
- }
81
- else if (t === '-->') { escaped = false; dbl = false; }
82
- else if (t[1] === '/') {
83
- if (dbl) dbl = false;
84
- else return m.index;
85
- } else if (escaped) dbl = true;
86
- }
87
- return -1;
88
- }
89
-
90
- /**
91
- * Byte ranges of `html` where a tag-shaped match is NOT an element (#1128).
92
- *
93
- * The element scanners below match tags with a flat regex over already-
94
- * assembled markup, which has no notion of an HTML context. So a registered tag
95
- * name written inside a comment used to be constructed and rendered as a real
96
- * element, and the replacement consumed the rest of the comment INCLUDING its
97
- * closing `-->`, leaving an unterminated comment that swallowed every following
98
- * byte. Whether it happened depended on whether the name in the comment was a
99
- * registered component, which is what made it look random.
100
- *
101
- * This is a single left-to-right pass rather than a search for `<!--`, because
102
- * the naive version introduces failures worse than the bug: an `<!--` inside an
103
- * attribute value (`title="use <!-- here"`) or inside RCDATA would open a region
104
- * that never closes, and every component after it would silently stop rendering.
105
- * Deciding that requires knowing the context, which means tokenizing, so the
106
- * pass tracks the same states the HTML parser does for these purposes:
107
- *
108
- * - **Comments**, including the spec's short forms. `<!-->` and `<!--->` close
109
- * immediately, `--!>` closes as well as `-->`, and an unterminated comment
110
- * runs to EOF, exactly as a browser would treat the same bytes.
111
- * - **Markup declarations and bogus comments** (`<!doctype …>`, `<![CDATA[…]]>`),
112
- * which end at the next `>`.
113
- * - **Tags**, consumed with their quoted attribute values, so `<` and `<!--`
114
- * inside an attribute are inert rather than context-changing.
115
- * - **Text-only elements**, whose content the HTML tokenizer never reads as
116
- * markup: raw text (`script`, `style`, `iframe`, `xmp`, `noembed`,
117
- * `noframes`, `plaintext`) and RCDATA (`textarea`, `title`). Their content is
118
- * returned as a skip range too, because a component tag inside a `<style>`
119
- * comment or an `<iframe>` fallback hit the identical markup-destroying path,
120
- * so excluding them would leave half the bug live.
121
- *
122
- * Two deliberate exclusions. `<template>` content IS parsed and legitimately
123
- * carries components (Declarative Shadow DOM and the streamed swap templates
124
- * both depend on that). `<noscript>` content is parsed as markup when
125
- * scripting is disabled, which for a progressive-enhancement framework is the
126
- * case that matters, so components inside it must keep rendering.
127
- *
128
- * @param {string} html
129
- * @returns {[number, number][]} ascending, non-overlapping `[start, end)` pairs
130
- */
131
- export function inertRanges(html) {
132
- /** @type {[number, number][]} */
133
- const ranges = [];
134
- const n = html.length;
135
- let i = 0;
136
- while (i < n) {
137
- const lt = html.indexOf('<', i);
138
- if (lt === -1) break;
139
- if (html.startsWith('<!--', lt)) {
140
- const end = endOfComment(html, lt);
141
- const stop = end === -1 ? n : end;
142
- ranges.push([lt, stop]);
143
- i = stop;
144
- continue;
145
- }
146
- if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {
147
- // Doctype / bogus comment / processing instruction: ends at the next `>`.
148
- const close = html.indexOf('>', lt);
149
- const end = close === -1 ? n : close + 1;
150
- ranges.push([lt, end]);
151
- i = end;
152
- continue;
153
- }
154
- const name = /^<\/?([a-zA-Z][^\s/>]*)/.exec(html.slice(lt, lt + 64));
155
- if (!name) {
156
- // `</` followed by anything that is not an ASCII letter is the third
157
- // bogus-comment form (`</1`, `</<`, `</ `), which the spec also runs to
158
- // the next `>`. Without this branch the bytes after it are scanned as
159
- // markup and a tag inside gets instantiated, which is the original bug.
160
- if (html.startsWith('</', lt)) {
161
- const close = html.indexOf('>', lt);
162
- const end = close === -1 ? n : close + 1;
163
- ranges.push([lt, end]);
164
- i = end;
165
- continue;
166
- }
167
- i = lt + 1;
168
- continue;
169
- }
170
- // Consume the tag, honouring quoted attribute values so a `<` or `<!--`
171
- // inside one cannot be mistaken for markup.
172
- //
173
- // A quote only OPENS a value when it directly follows `=`. That condition
174
- // is load-bearing rather than pedantic: `escapeAttr` does not escape `'`,
175
- // so an interpolated apostrophe in a single-quoted attribute emits three
176
- // unbalanced quotes (`title='don't'`). Treating every quote as a delimiter
177
- // left the scanner stuck inside a value to EOF, which returned a truncated
178
- // range list and silently re-enabled this whole bug for the rest of the
179
- // page. A browser recovers at the `>`, and so does this: after the value
180
- // closes, the stray `'` is just an attribute-name character.
181
- let p = lt + 1;
182
- let quote = '';
183
- // `expectValue` is set by `=` and cleared by the first non-whitespace
184
- // character after it. Only THAT character can open a quoted value, which is
185
- // what the spec does: before-attribute-value reconsumes anything else in
186
- // attribute-value-unquoted state. Keying off "the previous character was
187
- // `=`" instead re-opens the hole on `<a title==">`, where the `"` is an
188
- // ordinary value character; an odd quote count then ran the scan to EOF and
189
- // returned one giant inert range, silently disabling this whole fix for the
190
- // rest of the page.
191
- let expectValue = false;
192
- // Unquoted values need their own state for two reasons the spec spells out
193
- // and a simpler scan gets wrong: `>` ends the tag from here (so `attr=>` is
194
- // a missing value, not a value of `>`), and `/` is an ordinary value
195
- // character, so an unquoted URL ending in `/` is NOT a self-closing solidus.
196
- let inUnquoted = false;
197
- let selfClosing = false;
198
- while (p < n) {
199
- const c = html[p];
200
- const isSpace = c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
201
- if (quote) {
202
- if (c === quote) quote = '';
203
- } else if (c === '>') {
204
- // Checked before the value branches: a `>` arriving where a value was
205
- // expected terminates the tag (`<a href=>`). Consuming it as a value
206
- // character ran the scan on to the NEXT `>`, which swallowed the real
207
- // tag end and re-armed the original bug for what followed.
208
- selfClosing = !inUnquoted && html[p - 1] === '/';
209
- p += 1;
210
- break;
211
- } else if (expectValue) {
212
- // The first non-whitespace character after `=` decides the value form.
213
- if (!isSpace) {
214
- if (c === '"' || c === "'") quote = c;
215
- else inUnquoted = true;
216
- expectValue = false;
217
- }
218
- } else if (inUnquoted) {
219
- if (isSpace) inUnquoted = false;
220
- } else if (c === '=') {
221
- expectValue = true;
222
- }
223
- p += 1;
224
- }
225
- // The tag's interior is not markup either. A component tag written inside
226
- // an attribute value (`title="renders a <my-card> element"`) was otherwise
227
- // instantiated in place, destroying the rest of the document exactly like
228
- // the comment case. Start at lt+1 so the tag's OWN opening `<` still
229
- // matches; only what is nested inside it is inert.
230
- if (p > lt + 1) ranges.push([lt + 1, p]);
231
- i = p;
232
- const tag = name[1].toLowerCase();
233
- const isClose = html[lt + 1] === '/';
234
- // A self-closing start tag has no content to skip. In HTML the `/` is
235
- // ignored, but in SVG and MathML foreign content it genuinely closes the
236
- // element, and `<svg><title/></svg>` otherwise finds no `</title`, runs the
237
- // range to EOF, and makes every component in the rest of the document
238
- // inert. Honouring `/>` costs only the malformed-HTML case (`<style/>`,
239
- // already broken authoring) and fails in the direction where components
240
- // keep rendering rather than silently vanishing.
241
- if (!isClose && !selfClosing && isTextOnlyTag(tag)) {
242
- // Everything up to the matching close tag is text, not markup.
243
- let contentEnd;
244
- if (tag === 'plaintext') {
245
- // `<plaintext>` has no end tag at all: the rest of the document is text.
246
- contentEnd = n;
247
- } else if (tag === 'script') {
248
- // Script data has the double-escaped state (#1134), so its real end is
249
- // not necessarily the first `</script`.
250
- const end = endOfScriptContent(html, p);
251
- contentEnd = end === -1 ? n : end;
252
- } else {
253
- const close = new RegExp(`</${tag}(?=[\\s/>])`, 'i').exec(html.slice(p));
254
- contentEnd = close ? p + close.index : n;
255
- }
256
- if (contentEnd > p) ranges.push([p, contentEnd]);
257
- i = contentEnd;
258
- }
259
- }
260
- return ranges;
261
- }
262
-
263
- /**
264
- * Random-access membership test over ascending, non-overlapping ranges, for
265
- * callers whose queries are NOT monotonic (findClosingTagInString resets its
266
- * regex cursors backward while pairing opens with closes). O(ranges) per call;
267
- * monotonic callers use `inertAt` below instead.
268
- *
269
- * @param {[number, number][]} ranges
270
- * @param {number} index
271
- * @returns {boolean}
272
- */
273
- export function inRanges(ranges, index) {
274
- for (const [start, end] of ranges) {
275
- if (start > index) return false;
276
- if (index < end) return true;
277
- }
278
- return false;
279
- }
280
-
281
- /**
282
- * A left-to-right membership test over ascending, non-overlapping ranges.
283
- *
284
- * Returns a function that answers "is this index inert?" and REMEMBERS how far
285
- * it has walked, so a caller scanning matches in increasing order pays O(ranges)
286
- * across the whole scan instead of O(ranges) per match. Restarting each time is
287
- * an O(tags x components) term, which is measurable on a large page: holding a
288
- * document at 40k tags and raising the component count adds hundreds of
289
- * milliseconds that the cursor removes.
290
- *
291
- * The cursor only ever moves forward, so callers MUST query in non-decreasing
292
- * index order. All three call sites do (`matchAll`, and the two loops that
293
- * consume their input left to right). A caller that needs random access should
294
- * scan `ranges` directly rather than reusing this.
295
- *
296
- * @param {[number, number][]} ranges
297
- * @returns {(index: number) => boolean}
298
- */
299
- export function inertAt(ranges) {
300
- let cursor = 0;
301
- return (index) => {
302
- while (cursor < ranges.length && ranges[cursor][1] <= index) cursor += 1;
303
- if (cursor >= ranges.length) return false;
304
- return index >= ranges[cursor][0];
305
- };
306
- }
307
-
308
- /**
309
- * Find the position of the matching closing tag for `tagName` starting from
310
- * `fromIndex` in `html`. Handles nested same-tag elements via depth tracking.
311
- * Returns the index of the `<` of `</tagName>`, or -1 if unclosed.
312
- *
313
- * @param {string} html
314
- * @param {number} fromIndex
315
- * @param {string} tagName
316
- * @returns {number}
24
+ * Raw-text elements for the purposes of HOLE escaping: a hole inside one is
25
+ * emitted as-is. Deliberately just these two: widening it would change how
26
+ * holes inside the other text-only elements are escaped.
27
+ * @param {string} tag
317
28
  */
318
- export function findClosingTagInString(html, fromIndex, tagName, inert) {
319
- const esc = escapeRegex(tagName);
320
- // Match same-name opening tags. Followed by a name-boundary character
321
- // so we don't accept <table> as opening <tab>.
322
- const openRe = new RegExp(`<${esc}(?:[\\s>/])`, 'gi');
323
- const closeRe = new RegExp(`</${esc}\\s*>`, 'gi');
324
- // A tag inside a comment, raw text, RCDATA, or an attribute value is text
325
- // and must count for NEITHER side of the depth ledger (#1133). Counting a
326
- // commented `<my-card>` as a nested open meant depth never returned to zero,
327
- // and matching a commented `</my-card>` as the close truncated the authored
328
- // children at the comment, so the projected content ended with an
329
- // unterminated `<!--` that a browser read as commenting out the real close
330
- // tags. Callers that already computed the ranges for this exact string pass
331
- // them; a caller that did not gets them computed here.
332
- const ranges = inert === undefined ? inertRanges(html) : inert;
333
- const next = (re) => {
334
- let m;
335
- while ((m = re.exec(html)) !== null) {
336
- if (ranges.length === 0 || !inRanges(ranges, m.index)) return m;
337
- }
338
- return null;
339
- };
340
- openRe.lastIndex = fromIndex;
341
- closeRe.lastIndex = fromIndex;
342
- let depth = 1;
343
- while (depth > 0) {
344
- const o = next(openRe);
345
- const c = next(closeRe);
346
- if (!c) return -1;
347
- if (o && o.index < c.index) {
348
- depth++;
349
- closeRe.lastIndex = o.index + 1;
350
- } else {
351
- depth--;
352
- if (depth === 0) return c.index;
353
- openRe.lastIndex = c.index + 1;
354
- }
355
- }
356
- return -1;
357
- }
358
-
359
- /** @param {string} s */
360
- export function escapeRegex(s) {
361
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
362
- }
363
-
364
- /** @param {string} tag */
365
29
  export function isRawtextTag(tag) {
366
30
  return tag === 'script' || tag === 'style';
367
31
  }
@@ -378,19 +42,16 @@ export function isRcdataTag(tag) {
378
42
  }
379
43
 
380
44
  /**
381
- * Elements whose content the HTML tokenizer never reads as markup, for the
382
- * purposes of `inertRanges` only (#1128).
383
- *
384
- * Deliberately NOT `isRawtextTag`, even though it overlaps: that predicate is
385
- * shared with the template tokenizer, where widening it would change how holes
386
- * inside those elements are escaped. This one answers a narrower question,
387
- * "can a tag-shaped string in here be a real element", and the answer is no for
388
- * every raw-text and RCDATA element, not just the two the template path cares
389
- * about. `<iframe>` with fallback markup is the realistic trigger.
45
+ * Elements whose content the HTML tokenizer never reads as markup (#1128):
46
+ * a tag-shaped string inside one is never an element. Every raw-text and
47
+ * RCDATA element, plus the legacy ones (`<iframe>` with fallback markup is the
48
+ * realistic trigger).
390
49
  *
391
50
  * `<noscript>` is excluded on purpose: its content IS parsed as markup when
392
51
  * scripting is disabled, which for a progressive-enhancement framework is the
393
- * case that matters, so components inside it must keep rendering.
52
+ * case that matters, so components inside it must keep rendering. `<template>`
53
+ * content is parsed too (Declarative Shadow DOM and the streamed swap
54
+ * templates depend on that).
394
55
  *
395
56
  * @param {string} tag
396
57
  * @returns {boolean}
@@ -0,0 +1,79 @@
1
+ import { escapeAttr } from '../escape.js';
2
+ import { decodeAttrEntities } from './text.js';
3
+ import { appendReflectedAttrs } from './attrs.js';
4
+
5
+ /**
6
+ * Serve a custom element's attributes as the element left them after the
7
+ * server pass, the way lit-ssr's `ElementRenderer.renderAttributes()` emits
8
+ * the instance's final attributes rather than the ones the tag was written
9
+ * with. The browser does the same after upgrade: an element that sets, changes
10
+ * or removes an attribute during its first update shows the result, so the
11
+ * served bytes now match the hydrated DOM instead of the source tag.
12
+ *
13
+ * Three cases, per attribute:
14
+ * - one the element added is appended (`appendReflectedAttrs`, unchanged);
15
+ * - one the element CHANGED is served with its new value;
16
+ * - one the element REMOVED is dropped from the tag.
17
+ * An attribute the element left alone keeps its source text BYTE FOR BYTE
18
+ * (its quoting, its entity spelling, its position), so a page whose
19
+ * components touch nothing renders exactly as before, which the elision
20
+ * on-versus-off differential relies on.
21
+ *
22
+ * What stays as written, and why each is safe:
23
+ * - `data-webjs-prop-*`: a parent's `.prop=${value}` binding, consumed into
24
+ * the property BEFORE the instance existed (`consumePropAttrs`), so the
25
+ * instance never holds it and reading it back would drop it. It is kept as
26
+ * written, because the browser decodes it again on upgrade.
27
+ * - `data-wj-host`: added AFTER this step by `withHostMarker`. When a
28
+ * re-processed string already carries it (a streamed boundary's children),
29
+ * it was seeded into the instance like any other attribute and is
30
+ * unchanged, so it is kept.
31
+ * - `data-wj-slot-owner`: the renderer stamps it on `<slot>` elements, not on
32
+ * hosts. A host can only carry it in a re-processed string, where it was
33
+ * seeded and is unchanged, so it is kept.
34
+ * - an attribute that came from a template hole: by this point its value is
35
+ * plain text in the opening tag, seeded like a static one, so the same
36
+ * rule applies. Serving the element's value cannot desynchronize the hole:
37
+ * a host inside a component's template is re-created by that component's
38
+ * client render, which writes the hole's value again, and a host written
39
+ * by a page or a layout never hydrates its attributes at all.
40
+ *
41
+ * @param {string} opening the element's opening tag, `<tag attrs>`, ending in `>`
42
+ * @param {any} instance the server-side instance (the element shim, or a real
43
+ * `HTMLElement` when `renderToString` runs in a browser)
44
+ * @param {Set<string>} presentAttrNames lowercased names already in the source tag
45
+ * @returns {string}
46
+ */
47
+ export function serveInstanceAttrs(opening, instance, presentAttrNames) {
48
+ if (!instance || typeof instance.getAttribute !== 'function' || typeof instance.getAttributeNames !== 'function') return opening;
49
+ const nameEnd = opening.search(/[\s>]/);
50
+ const head = opening.slice(0, nameEnd);
51
+ const body = opening.slice(nameEnd, -1);
52
+ // The same attribute grammar `parseAttrs` reads the source with, so every
53
+ // name seeded into the instance is found again here.
54
+ const re = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
55
+ let out = '';
56
+ let last = 0;
57
+ let changed = false;
58
+ let m;
59
+ while ((m = re.exec(body)) !== null) {
60
+ const raw = m[0];
61
+ const name = m[1];
62
+ if (name.toLowerCase().startsWith('data-webjs-prop-')) continue;
63
+ const written = decodeAttrEntities(m[2] ?? m[3] ?? m[4] ?? '');
64
+ const now = instance.getAttribute(name);
65
+ if (now === written) continue;
66
+ changed = true;
67
+ if (now === null) {
68
+ // Removed: drop it together with the whitespace that separated it.
69
+ let start = m.index;
70
+ while (start > last && /\s/.test(body[start - 1])) start--;
71
+ out += body.slice(last, start);
72
+ } else {
73
+ out += body.slice(last, m.index) + (now === '' ? name : `${name}="${escapeAttr(String(now))}"`);
74
+ }
75
+ last = m.index + raw.length;
76
+ }
77
+ const rewritten = changed ? `${head}${out}${body.slice(last)}>` : opening;
78
+ return appendReflectedAttrs(rewritten, instance, presentAttrNames);
79
+ }
@@ -1,7 +1,7 @@
1
1
  import { cspNonce } from '../csp-nonce.js';
2
2
  import { escapeAttr } from '../escape.js';
3
- import { render, defaultSSRErrorTemplate } from './template-renderer.js';
4
- import { injectDSD } from './dsd.js';
3
+ import { defaultSSRErrorTemplate, streamRender } from './template-renderer.js';
4
+ import { renderValue } from './walk.js';
5
5
 
6
6
  /**
7
7
  * Render a TemplateResult (or any renderable value) to an HTML string.
@@ -39,8 +39,7 @@ export async function renderToString(value, opts = { ssr: true }) {
39
39
  // sharing it see the same flag. Undefined stays undefined (NODE_ENV fallback).
40
40
  const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
41
41
  if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
42
- const html = await render(value, ctx);
43
- return opts && opts.ssr === false ? html : await injectDSD(html, ctx, [], dev);
42
+ return renderValue(value, ctx, { expand: !(opts && opts.ssr === false), dev });
44
43
  }
45
44
 
46
45
  /**
@@ -72,14 +71,12 @@ export function renderToStream(value, opts = { ssr: true }) {
72
71
  try {
73
72
  if (opts && opts.ssr === false) {
74
73
  // No DSD injection: just stream the raw rendered chunks.
75
- const { streamRender } = await import('./template-renderer.js');
76
74
  await streamRender(value, ctx, controller);
77
75
  } else {
78
- // Render to string first to run DSD injection (which operates on
79
- // the full HTML), then enqueue the result. This matches the
80
- // semantics of renderToString but still gives us a stream.
81
- const html = await render(value, ctx);
82
- const full = await injectDSD(html, ctx, [], dev);
76
+ // Render the whole document first (the server pipeline slices the
77
+ // full body for frames, `X-Webjs-Have` and the head splice), then
78
+ // enqueue it. Same semantics as renderToString, as a stream.
79
+ const full = await renderValue(value, ctx, { expand: true, dev });
83
80
  controller.enqueue(full);
84
81
  }
85
82
 
@@ -120,8 +117,9 @@ async function streamSuspenseBoundaries(ctx, controller, dev) {
120
117
  batch.map(async ({ id, promise }) => {
121
118
  try {
122
119
  const resolved = await promise;
123
- const html = await render(resolved, ctx);
124
- const full = await injectDSD(html, ctx, [], dev);
120
+ // A streamed `<webjs-suspense>` pushes its children as raw markup
121
+ // (`unsafeHTML`), which the walk expands like any template.
122
+ const full = await renderValue(resolved, ctx, { expand: true, dev });
125
123
  controller.enqueue(
126
124
  `<template data-webjs-resolve="${id}">${full}</template>` +
127
125
  `<script${nonceAttr}>` +
@@ -141,7 +139,7 @@ async function streamSuspenseBoundaries(ctx, controller, dev) {
141
139
  // error render itself throwing) leaves the fallback in place.
142
140
  try {
143
141
  const e = err instanceof Error ? err : new Error(String(err));
144
- const errHtml = await injectDSD(await render(defaultSSRErrorTemplate('webjs-suspense', e, dev), ctx), ctx, [], dev);
142
+ const errHtml = await renderValue(defaultSSRErrorTemplate('webjs-suspense', e, dev), ctx, { expand: true, dev });
145
143
  controller.enqueue(
146
144
  `<template data-webjs-resolve="${id}">${errHtml}</template>` +
147
145
  `<script${nonceAttr}>` +