@webjsdev/core 0.7.44 → 0.7.46

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.
@@ -1,6 +1,7 @@
1
1
  import { html, isTemplate } from './html.js';
2
2
  import { BINDING_PREFIXES } from './binding-prefixes.js';
3
3
  import { escapeText, escapeAttr } from './escape.js';
4
+ import { assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp } from './form-action.js';
4
5
  import { lookup, lookupModuleUrl, allTags } from './registry.js';
5
6
  import { stylesToString, isCSS } from './css.js';
6
7
  import { isRepeat } from './repeat.js';
@@ -289,6 +290,17 @@ async function renderTemplate(tr, ctx) {
289
290
  continue;
290
291
  }
291
292
  if (!currentTag.includes('-')) {
293
+ // A native element's `.prop` is dropped at SSR, so this path never
294
+ // leaked here. It still refuses a function where the property is a
295
+ // REFLECTED IDL attribute (`.action` on a form, `.formAction` on a
296
+ // button or input), so the rule does not depend on which renderer
297
+ // sees it first: the client sets that property for real and the
298
+ // reflection writes the source into the DOM. A page that renders
299
+ // clean on the server and throws on hydration is a worse failure
300
+ // than one that refuses at the earliest point. Elsewhere the
301
+ // property is a plain expando that reflects nothing, so refusing
302
+ // it would be a false positive.
303
+ assertNotFunctionReflectedActionProp(val, name, currentTag);
292
304
  state = 'in-tag';
293
305
  attrName = '';
294
306
  continue;
@@ -318,16 +330,26 @@ async function renderTemplate(tr, ctx) {
318
330
  state = 'in-tag';
319
331
  attrName = '';
320
332
  } else if (kind === 'bool') {
333
+ // Never leaked (a boolean binding stringifies nothing), but
334
+ // `?action=${fn}` is meaningless in every case and refusing it keeps
335
+ // the rule true for every sigil rather than only the quoted ones.
336
+ assertNotFunctionActionAttr(val, name, currentTag);
321
337
  out = out.slice(0, attrStart);
322
338
  if (val) out += `${name}=""`;
323
339
  state = 'in-tag';
324
340
  attrName = '';
325
341
  } else {
342
+ // #1154: never stringify a function into action=/formaction= (it
343
+ // would serialize a server action's source into the served HTML).
344
+ assertNotFunctionActionAttr(val, attrName, currentTag);
326
345
  out += `"${escapeAttr(String(val ?? ''))}"`;
327
346
  state = 'in-tag';
328
347
  attrName = '';
329
348
  }
330
349
  } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
350
+ // Same guard for a hole inside a quoted/unquoted value, the
351
+ // `action="${fn}"` and mixed `action="/x/${fn}"` shapes (#1154).
352
+ assertNotFunctionActionAttr(val, attrName, currentTag);
331
353
  out += escapeAttr(String(val ?? ''));
332
354
  }
333
355
  }
@@ -411,6 +433,294 @@ function defaultSSRErrorTemplate(tag, err, dev) {
411
433
  </div>`;
412
434
  }
413
435
 
436
+ /**
437
+ * Index just past the end of the comment starting at `start`, or -1 if it is
438
+ * unterminated. Shared so every scanner that has to decide where a comment
439
+ * stops agrees, including on the spec short forms.
440
+ *
441
+ * @param {string} html
442
+ * @param {number} start index of the `<` of `<!--`
443
+ * @returns {number}
444
+ */
445
+ function endOfComment(html, start) {
446
+ let p = start + 4;
447
+ // `<!-->` and `<!--->` are comments whose data is empty (spec short forms).
448
+ if (html[p] === '>') return p + 1;
449
+ if (html.startsWith('->', p)) return p + 2;
450
+ while (p < html.length) {
451
+ // `--!>` is the spec's "abrupt closing" form and closes just like `-->`.
452
+ if (html.startsWith('-->', p)) return p + 3;
453
+ if (html.startsWith('--!>', p)) return p + 4;
454
+ p += 1;
455
+ }
456
+ return -1;
457
+ }
458
+
459
+ /**
460
+ * Index of the `</script` that really closes a `<script>` whose content starts
461
+ * at `from`, or -1 when unterminated (#1134).
462
+ *
463
+ * Script data is not plain raw text: once the content contains `<!--` followed
464
+ * by `<script`, the tokenizer is in the script-data-double-escaped state, where
465
+ * a `</script>` is TEXT (it only steps back to the escaped state) and the
466
+ * element ends at the NEXT `</script>`. The legacy comment-wrapped inline
467
+ * script that document.writes a script tag is the pattern that produces this.
468
+ * Stopping at the first `</script>` there re-opened the original #1128 bug in
469
+ * the one element the scanner most explicitly claims to handle.
470
+ *
471
+ * @param {string} html
472
+ * @param {number} from index just past the opening tag's `>`
473
+ * @returns {number}
474
+ */
475
+ function endOfScriptContent(html, from) {
476
+ const re = /<!--|-->|<\/script(?=[\s/>])|<script(?=[\s/>])/gi;
477
+ re.lastIndex = from;
478
+ let escaped = false;
479
+ let dbl = false;
480
+ let m;
481
+ while ((m = re.exec(html)) !== null) {
482
+ const t = m[0];
483
+ if (t === '<!--') {
484
+ // The token's own trailing `--` puts the tokenizer in a dash-dash state
485
+ // REGARDLESS of what state it was in (`<!` are inert bytes in escaped and
486
+ // double-escaped states too), and every dash-dash state exits straight
487
+ // back to plain script data on `>`. So `<!-->`, `<!--->`, and any dash
488
+ // run followed by `>` clear BOTH flags: entering fresh it cancels the
489
+ // escape before it starts, and inside an escaped or double-escaped body
490
+ // it is the exit a browser honours, after which the element ends at the
491
+ // next `</script>`.
492
+ let q = m.index + 4;
493
+ while (html[q] === '-') q += 1;
494
+ if (html[q] === '>') { escaped = false; dbl = false; re.lastIndex = q + 1; }
495
+ else if (!escaped) escaped = true;
496
+ }
497
+ else if (t === '-->') { escaped = false; dbl = false; }
498
+ else if (t[1] === '/') {
499
+ if (dbl) dbl = false;
500
+ else return m.index;
501
+ } else if (escaped) dbl = true;
502
+ }
503
+ return -1;
504
+ }
505
+
506
+ /**
507
+ * Byte ranges of `html` where a tag-shaped match is NOT an element (#1128).
508
+ *
509
+ * The element scanners below match tags with a flat regex over already-
510
+ * assembled markup, which has no notion of an HTML context. So a registered tag
511
+ * name written inside a comment used to be constructed and rendered as a real
512
+ * element, and the replacement consumed the rest of the comment INCLUDING its
513
+ * closing `-->`, leaving an unterminated comment that swallowed every following
514
+ * byte. Whether it happened depended on whether the name in the comment was a
515
+ * registered component, which is what made it look random.
516
+ *
517
+ * This is a single left-to-right pass rather than a search for `<!--`, because
518
+ * the naive version introduces failures worse than the bug: an `<!--` inside an
519
+ * attribute value (`title="use <!-- here"`) or inside RCDATA would open a region
520
+ * that never closes, and every component after it would silently stop rendering.
521
+ * Deciding that requires knowing the context, which means tokenizing, so the
522
+ * pass tracks the same states the HTML parser does for these purposes:
523
+ *
524
+ * - **Comments**, including the spec's short forms. `<!-->` and `<!--->` close
525
+ * immediately, `--!>` closes as well as `-->`, and an unterminated comment
526
+ * runs to EOF, exactly as a browser would treat the same bytes.
527
+ * - **Markup declarations and bogus comments** (`<!doctype …>`, `<![CDATA[…]]>`),
528
+ * which end at the next `>`.
529
+ * - **Tags**, consumed with their quoted attribute values, so `<` and `<!--`
530
+ * inside an attribute are inert rather than context-changing.
531
+ * - **Text-only elements**, whose content the HTML tokenizer never reads as
532
+ * markup: raw text (`script`, `style`, `iframe`, `xmp`, `noembed`,
533
+ * `noframes`, `plaintext`) and RCDATA (`textarea`, `title`). Their content is
534
+ * returned as a skip range too, because a component tag inside a `<style>`
535
+ * comment or an `<iframe>` fallback hit the identical markup-destroying path,
536
+ * so excluding them would leave half the bug live.
537
+ *
538
+ * Two deliberate exclusions. `<template>` content IS parsed and legitimately
539
+ * carries components (Declarative Shadow DOM and the streamed swap templates
540
+ * both depend on that). `<noscript>` content is parsed as markup when
541
+ * scripting is disabled, which for a progressive-enhancement framework is the
542
+ * case that matters, so components inside it must keep rendering.
543
+ *
544
+ * @param {string} html
545
+ * @returns {[number, number][]} ascending, non-overlapping `[start, end)` pairs
546
+ */
547
+ function inertRanges(html) {
548
+ /** @type {[number, number][]} */
549
+ const ranges = [];
550
+ const n = html.length;
551
+ let i = 0;
552
+ while (i < n) {
553
+ const lt = html.indexOf('<', i);
554
+ if (lt === -1) break;
555
+ if (html.startsWith('<!--', lt)) {
556
+ const end = endOfComment(html, lt);
557
+ const stop = end === -1 ? n : end;
558
+ ranges.push([lt, stop]);
559
+ i = stop;
560
+ continue;
561
+ }
562
+ if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {
563
+ // Doctype / bogus comment / processing instruction: ends at the next `>`.
564
+ const close = html.indexOf('>', lt);
565
+ const end = close === -1 ? n : close + 1;
566
+ ranges.push([lt, end]);
567
+ i = end;
568
+ continue;
569
+ }
570
+ const name = /^<\/?([a-zA-Z][^\s/>]*)/.exec(html.slice(lt, lt + 64));
571
+ if (!name) {
572
+ // `</` followed by anything that is not an ASCII letter is the third
573
+ // bogus-comment form (`</1`, `</<`, `</ `), which the spec also runs to
574
+ // the next `>`. Without this branch the bytes after it are scanned as
575
+ // markup and a tag inside gets instantiated, which is the original bug.
576
+ if (html.startsWith('</', lt)) {
577
+ const close = html.indexOf('>', lt);
578
+ const end = close === -1 ? n : close + 1;
579
+ ranges.push([lt, end]);
580
+ i = end;
581
+ continue;
582
+ }
583
+ i = lt + 1;
584
+ continue;
585
+ }
586
+ // Consume the tag, honouring quoted attribute values so a `<` or `<!--`
587
+ // inside one cannot be mistaken for markup.
588
+ //
589
+ // A quote only OPENS a value when it directly follows `=`. That condition
590
+ // is load-bearing rather than pedantic: `escapeAttr` does not escape `'`,
591
+ // so an interpolated apostrophe in a single-quoted attribute emits three
592
+ // unbalanced quotes (`title='don't'`). Treating every quote as a delimiter
593
+ // left the scanner stuck inside a value to EOF, which returned a truncated
594
+ // range list and silently re-enabled this whole bug for the rest of the
595
+ // page. A browser recovers at the `>`, and so does this: after the value
596
+ // closes, the stray `'` is just an attribute-name character.
597
+ let p = lt + 1;
598
+ let quote = '';
599
+ // `expectValue` is set by `=` and cleared by the first non-whitespace
600
+ // character after it. Only THAT character can open a quoted value, which is
601
+ // what the spec does: before-attribute-value reconsumes anything else in
602
+ // attribute-value-unquoted state. Keying off "the previous character was
603
+ // `=`" instead re-opens the hole on `<a title==">`, where the `"` is an
604
+ // ordinary value character; an odd quote count then ran the scan to EOF and
605
+ // returned one giant inert range, silently disabling this whole fix for the
606
+ // rest of the page.
607
+ let expectValue = false;
608
+ // Unquoted values need their own state for two reasons the spec spells out
609
+ // and a simpler scan gets wrong: `>` ends the tag from here (so `attr=>` is
610
+ // a missing value, not a value of `>`), and `/` is an ordinary value
611
+ // character, so an unquoted URL ending in `/` is NOT a self-closing solidus.
612
+ let inUnquoted = false;
613
+ let selfClosing = false;
614
+ while (p < n) {
615
+ const c = html[p];
616
+ const isSpace = c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
617
+ if (quote) {
618
+ if (c === quote) quote = '';
619
+ } else if (c === '>') {
620
+ // Checked before the value branches: a `>` arriving where a value was
621
+ // expected terminates the tag (`<a href=>`). Consuming it as a value
622
+ // character ran the scan on to the NEXT `>`, which swallowed the real
623
+ // tag end and re-armed the original bug for what followed.
624
+ selfClosing = !inUnquoted && html[p - 1] === '/';
625
+ p += 1;
626
+ break;
627
+ } else if (expectValue) {
628
+ // The first non-whitespace character after `=` decides the value form.
629
+ if (!isSpace) {
630
+ if (c === '"' || c === "'") quote = c;
631
+ else inUnquoted = true;
632
+ expectValue = false;
633
+ }
634
+ } else if (inUnquoted) {
635
+ if (isSpace) inUnquoted = false;
636
+ } else if (c === '=') {
637
+ expectValue = true;
638
+ }
639
+ p += 1;
640
+ }
641
+ // The tag's interior is not markup either. A component tag written inside
642
+ // an attribute value (`title="renders a <my-card> element"`) was otherwise
643
+ // instantiated in place, destroying the rest of the document exactly like
644
+ // the comment case. Start at lt+1 so the tag's OWN opening `<` still
645
+ // matches; only what is nested inside it is inert.
646
+ if (p > lt + 1) ranges.push([lt + 1, p]);
647
+ i = p;
648
+ const tag = name[1].toLowerCase();
649
+ const isClose = html[lt + 1] === '/';
650
+ // A self-closing start tag has no content to skip. In HTML the `/` is
651
+ // ignored, but in SVG and MathML foreign content it genuinely closes the
652
+ // element, and `<svg><title/></svg>` otherwise finds no `</title`, runs the
653
+ // range to EOF, and makes every component in the rest of the document
654
+ // inert. Honouring `/>` costs only the malformed-HTML case (`<style/>`,
655
+ // already broken authoring) and fails in the direction where components
656
+ // keep rendering rather than silently vanishing.
657
+ if (!isClose && !selfClosing && isTextOnlyTag(tag)) {
658
+ // Everything up to the matching close tag is text, not markup.
659
+ let contentEnd;
660
+ if (tag === 'plaintext') {
661
+ // `<plaintext>` has no end tag at all: the rest of the document is text.
662
+ contentEnd = n;
663
+ } else if (tag === 'script') {
664
+ // Script data has the double-escaped state (#1134), so its real end is
665
+ // not necessarily the first `</script`.
666
+ const end = endOfScriptContent(html, p);
667
+ contentEnd = end === -1 ? n : end;
668
+ } else {
669
+ const close = new RegExp(`</${tag}(?=[\\s/>])`, 'i').exec(html.slice(p));
670
+ contentEnd = close ? p + close.index : n;
671
+ }
672
+ if (contentEnd > p) ranges.push([p, contentEnd]);
673
+ i = contentEnd;
674
+ }
675
+ }
676
+ return ranges;
677
+ }
678
+
679
+ /**
680
+ * Random-access membership test over ascending, non-overlapping ranges, for
681
+ * callers whose queries are NOT monotonic (findClosingTagInString resets its
682
+ * regex cursors backward while pairing opens with closes). O(ranges) per call;
683
+ * monotonic callers use `inertAt` below instead.
684
+ *
685
+ * @param {[number, number][]} ranges
686
+ * @param {number} index
687
+ * @returns {boolean}
688
+ */
689
+ function inRanges(ranges, index) {
690
+ for (const [start, end] of ranges) {
691
+ if (start > index) return false;
692
+ if (index < end) return true;
693
+ }
694
+ return false;
695
+ }
696
+
697
+ /**
698
+ * A left-to-right membership test over ascending, non-overlapping ranges.
699
+ *
700
+ * Returns a function that answers "is this index inert?" and REMEMBERS how far
701
+ * it has walked, so a caller scanning matches in increasing order pays O(ranges)
702
+ * across the whole scan instead of O(ranges) per match. Restarting each time is
703
+ * an O(tags x components) term, which is measurable on a large page: holding a
704
+ * document at 40k tags and raising the component count adds hundreds of
705
+ * milliseconds that the cursor removes.
706
+ *
707
+ * The cursor only ever moves forward, so callers MUST query in non-decreasing
708
+ * index order. All three call sites do (`matchAll`, and the two loops that
709
+ * consume their input left to right). A caller that needs random access should
710
+ * scan `ranges` directly rather than reusing this.
711
+ *
712
+ * @param {[number, number][]} ranges
713
+ * @returns {(index: number) => boolean}
714
+ */
715
+ function inertAt(ranges) {
716
+ let cursor = 0;
717
+ return (index) => {
718
+ while (cursor < ranges.length && ranges[cursor][1] <= index) cursor += 1;
719
+ if (cursor >= ranges.length) return false;
720
+ return index >= ranges[cursor][0];
721
+ };
722
+ }
723
+
414
724
  /**
415
725
  * Scan an HTML string for registered custom elements and inject
416
726
  * Declarative Shadow DOM (`<template shadowrootmode="open">`).
@@ -447,10 +757,15 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
447
757
  );
448
758
  /** @type {{start:number, end:number, text:string}[]} */
449
759
  const edits = [];
760
+ // A tag name inside a comment, a script, a style, RCDATA, or another tag's
761
+ // attribute value is text, not an element (#1128).
762
+ const inert = inertRanges(html);
763
+ const isInert = inertAt(inert);
450
764
  for (const m of html.matchAll(pattern)) {
451
765
  const [match, tag, attrs, selfClose] = m;
452
766
  const Cls = lookup(tag);
453
767
  if (!Cls) continue;
768
+ if (isInert(m.index)) continue;
454
769
  // Track which custom elements actually appeared: used by SSR to emit
455
770
  // `<link rel="modulepreload">` hints for their module URLs.
456
771
  if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
@@ -496,7 +811,7 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
496
811
  let closeEnd = m.index + match.length;
497
812
  if (!selfClose) {
498
813
  const innerStart = m.index + match.length;
499
- const closeIdx = findClosingTagInString(html, innerStart, tag);
814
+ const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
500
815
  if (closeIdx !== -1) {
501
816
  authoredInner = html.slice(innerStart, closeIdx);
502
817
  const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
@@ -644,7 +959,7 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
644
959
  let closeEnd = m.index + match.length;
645
960
  if (!selfClose) {
646
961
  const innerStart = m.index + match.length;
647
- const closeIdx = findClosingTagInString(html, innerStart, tag);
962
+ const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
648
963
  if (closeIdx !== -1) {
649
964
  const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
650
965
  const cm = closeRe.exec(html.slice(closeIdx));
@@ -753,6 +1068,17 @@ function isVoidElement(tag) {
753
1068
  async function processSuspenseElements(html, ctx, ancestors = [], dev) {
754
1069
  if (html.indexOf('<webjs-suspense') === -1) return html;
755
1070
  const OPEN = /<webjs-suspense((?:"[^"]*"|'[^']*'|[^>])*?)>/i;
1071
+ // A commented-out boundary is text, not an element (#1128). This scanner is
1072
+ // the reason the comment fix cannot live in injectDSD alone: it runs FIRST
1073
+ // and hands the boundary's children to a fresh injectDSD as a standalone
1074
+ // string, which has no idea those bytes came from inside a comment. Under
1075
+ // streaming it is worse than a stray render, because the children's data
1076
+ // fetches run and the swap script targets an id that only exists inside a
1077
+ // comment, so it can never resolve. Ranges are computed against the FULL
1078
+ // input once, and `consumed` maps the shrinking `rest` back onto it.
1079
+ const inert = inertRanges(html);
1080
+ const isInert = inertAt(inert);
1081
+ let consumed = 0;
756
1082
  let result = '';
757
1083
  let rest = html;
758
1084
  // Bounded loop: each iteration consumes at least the opening tag.
@@ -762,6 +1088,14 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
762
1088
  result += rest;
763
1089
  break;
764
1090
  }
1091
+ if (isInert(consumed + m.index)) {
1092
+ // Emit through the end of this match and keep scanning after it.
1093
+ const skipTo = m.index + m[0].length;
1094
+ result += rest.slice(0, skipTo);
1095
+ rest = rest.slice(skipTo);
1096
+ consumed += skipTo;
1097
+ continue;
1098
+ }
765
1099
  const openStart = m.index;
766
1100
  const openEnd = m.index + m[0].length;
767
1101
  result += rest.slice(0, openStart);
@@ -769,7 +1103,20 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
769
1103
  const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
770
1104
  const fallbackHtml = fbMatch ? unescapeAttr(fbMatch[1]) : '';
771
1105
 
772
- const closeIdx = findClosingTagInString(rest, openEnd, 'webjs-suspense');
1106
+ // Pass the FULL-input ranges shifted into `rest` coordinates rather than
1107
+ // letting the helper re-tokenize the suffix. `rest` can begin mid-comment
1108
+ // or mid-raw-text after the skip path above, and a tokenizer restarted
1109
+ // there is in the wrong state: a text-only opener named later in that same
1110
+ // comment would read as a real element and mark everything to EOF inert,
1111
+ // so the boundary's real close tag was skipped and the trailing markup
1112
+ // folded into the boundary. The shifted view keeps the full-string truth,
1113
+ // including a first range that starts before `rest` does.
1114
+ const shifted = [];
1115
+ for (const [s, e] of inert) {
1116
+ if (e <= consumed) continue;
1117
+ shifted.push([Math.max(0, s - consumed), e - consumed]);
1118
+ }
1119
+ const closeIdx = findClosingTagInString(rest, openEnd, 'webjs-suspense', shifted);
773
1120
  let inner;
774
1121
  let afterClose;
775
1122
  if (closeIdx === -1) {
@@ -792,6 +1139,7 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
792
1139
  const innerProcessed = await injectDSD(inner, ctx, ancestors, dev);
793
1140
  result += `<webjs-suspense>${innerProcessed}</webjs-suspense>`;
794
1141
  }
1142
+ consumed += rest.length - afterClose.length;
795
1143
  rest = afterClose;
796
1144
  }
797
1145
  return result;
@@ -807,18 +1155,34 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
807
1155
  * @param {string} tagName
808
1156
  * @returns {number}
809
1157
  */
810
- function findClosingTagInString(html, fromIndex, tagName) {
1158
+ function findClosingTagInString(html, fromIndex, tagName, inert) {
811
1159
  const esc = escapeRegex(tagName);
812
1160
  // Match same-name opening tags. Followed by a name-boundary character
813
1161
  // so we don't accept <table> as opening <tab>.
814
1162
  const openRe = new RegExp(`<${esc}(?:[\\s>/])`, 'gi');
815
1163
  const closeRe = new RegExp(`</${esc}\\s*>`, 'gi');
1164
+ // A tag inside a comment, raw text, RCDATA, or an attribute value is text
1165
+ // and must count for NEITHER side of the depth ledger (#1133). Counting a
1166
+ // commented `<my-card>` as a nested open meant depth never returned to zero,
1167
+ // and matching a commented `</my-card>` as the close truncated the authored
1168
+ // children at the comment, so the projected content ended with an
1169
+ // unterminated `<!--` that a browser read as commenting out the real close
1170
+ // tags. Callers that already computed the ranges for this exact string pass
1171
+ // them; a caller that did not gets them computed here.
1172
+ const ranges = inert === undefined ? inertRanges(html) : inert;
1173
+ const next = (re) => {
1174
+ let m;
1175
+ while ((m = re.exec(html)) !== null) {
1176
+ if (ranges.length === 0 || !inRanges(ranges, m.index)) return m;
1177
+ }
1178
+ return null;
1179
+ };
816
1180
  openRe.lastIndex = fromIndex;
817
1181
  closeRe.lastIndex = fromIndex;
818
1182
  let depth = 1;
819
1183
  while (depth > 0) {
820
- const o = openRe.exec(html);
821
- const c = closeRe.exec(html);
1184
+ const o = next(openRe);
1185
+ const c = next(closeRe);
822
1186
  if (!c) return -1;
823
1187
  if (o && o.index < c.index) {
824
1188
  depth++;
@@ -875,14 +1239,20 @@ function partitionAuthoredBySlot(html) {
875
1239
  if (lt > cursor) defaultBuf += html.slice(cursor, lt);
876
1240
  const rest = html.slice(lt);
877
1241
  if (rest.startsWith('<!--')) {
878
- const end = html.indexOf('-->', lt + 4);
879
- if (end === -1) {
1242
+ // Find the comment's end the same way inertRanges does, rather than with
1243
+ // a bare `indexOf('-->')`. The two helpers both decide where a comment
1244
+ // stops, so a bare search makes them DISAGREE on the spec short forms
1245
+ // (`--!>`, `<!-->`, `<!--->`): this one would run past the real end and
1246
+ // swallow the slotted children that follow, silently routing a
1247
+ // `slot="head"` child into the default slot.
1248
+ const commentEnd = endOfComment(html, lt);
1249
+ if (commentEnd === -1) {
880
1250
  defaultBuf += rest;
881
1251
  cursor = html.length;
882
1252
  break;
883
1253
  }
884
- defaultBuf += html.slice(lt, end + 3);
885
- cursor = end + 3;
1254
+ defaultBuf += html.slice(lt, commentEnd);
1255
+ cursor = commentEnd;
886
1256
  continue;
887
1257
  }
888
1258
  if (rest.startsWith('<!') || rest.startsWith('</')) {
@@ -968,8 +1338,16 @@ function substituteSlotsInRender(rendered, partitioned, ownerTag) {
968
1338
  let result = '';
969
1339
  let cursor = 0;
970
1340
  const slotRe = /<slot((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/gi;
1341
+ // A `<slot>` written inside a comment is documentation, not a slot (#1128).
1342
+ // Substituting one is worse here than in the element walk: a commented slot
1343
+ // has no `</slot>`, so the fallback scan below swallows the rest of the
1344
+ // template, the component's REAL slot is never substituted, and the authored
1345
+ // children are dropped from the page entirely.
1346
+ const inert = inertRanges(rendered);
1347
+ const isInert = inertAt(inert);
971
1348
  let m;
972
1349
  while ((m = slotRe.exec(rendered)) !== null) {
1350
+ if (isInert(m.index)) continue;
973
1351
  result += rendered.slice(cursor, m.index);
974
1352
  const [fullOpen, attrsRaw, selfCloseSlash] = m;
975
1353
  const isSelfClose = !!selfCloseSlash;
@@ -984,7 +1362,7 @@ function substituteSlotsInRender(rendered, partitioned, ownerTag) {
984
1362
  totalEnd = m.index + fullOpen.length;
985
1363
  } else {
986
1364
  const innerStart = m.index + fullOpen.length;
987
- const closeIdx = findClosingTagInString(rendered, innerStart, 'slot');
1365
+ const closeIdx = findClosingTagInString(rendered, innerStart, 'slot', inert);
988
1366
  if (closeIdx === -1) {
989
1367
  fallback = rendered.slice(innerStart);
990
1368
  totalEnd = rendered.length;
@@ -1028,6 +1406,41 @@ function isRawtextTag(tag) {
1028
1406
  return tag === 'script' || tag === 'style';
1029
1407
  }
1030
1408
 
1409
+ /**
1410
+ * RCDATA elements: their content is text (character references aside), so a
1411
+ * tag-shaped string inside one is not markup. Kept next to `isRawtextTag` so
1412
+ * the two lists stay together rather than drifting apart.
1413
+ * @param {string} tag
1414
+ * @returns {boolean}
1415
+ */
1416
+ function isRcdataTag(tag) {
1417
+ return tag === 'textarea' || tag === 'title';
1418
+ }
1419
+
1420
+ /**
1421
+ * Elements whose content the HTML tokenizer never reads as markup, for the
1422
+ * purposes of `inertRanges` only (#1128).
1423
+ *
1424
+ * Deliberately NOT `isRawtextTag`, even though it overlaps: that predicate is
1425
+ * shared with the template tokenizer, where widening it would change how holes
1426
+ * inside those elements are escaped. This one answers a narrower question,
1427
+ * "can a tag-shaped string in here be a real element", and the answer is no for
1428
+ * every raw-text and RCDATA element, not just the two the template path cares
1429
+ * about. `<iframe>` with fallback markup is the realistic trigger.
1430
+ *
1431
+ * `<noscript>` is excluded on purpose: its content IS parsed as markup when
1432
+ * scripting is disabled, which for a progressive-enhancement framework is the
1433
+ * case that matters, so components inside it must keep rendering.
1434
+ *
1435
+ * @param {string} tag
1436
+ * @returns {boolean}
1437
+ */
1438
+ function isTextOnlyTag(tag) {
1439
+ return isRawtextTag(tag) || isRcdataTag(tag)
1440
+ || tag === 'iframe' || tag === 'xmp' || tag === 'noembed'
1441
+ || tag === 'noframes' || tag === 'plaintext';
1442
+ }
1443
+
1031
1444
  /**
1032
1445
  * Minimal attribute string parser.
1033
1446
  * @param {string} attrStr
@@ -1484,20 +1897,45 @@ async function streamTemplate(tr, ctx, controller) {
1484
1897
  const name = attrName.slice(1);
1485
1898
  const kind = BINDING_PREFIXES[prefix];
1486
1899
  if (kind === 'event' || kind === 'prop') {
1900
+ // Guard `prop` ONLY, matching the buffered machine. An `@action`
1901
+ // event binding is dropped here and never stringified, and a
1902
+ // function is the LEGITIMATE value for one (`<my-el @action=${fn}>`
1903
+ // listens for an `action` event), so refusing it would be a false
1904
+ // positive. `.action` differs where the property REFLECTS: on a form
1905
+ // (and `.formAction` on a button or input) the client assignment
1906
+ // writes the source into the DOM, so refusing at SSR keeps a page
1907
+ // from rendering clean on the server and throwing on hydration.
1908
+ // Elsewhere, including a custom element, the property reflects
1909
+ // nothing and a function stays legal.
1910
+ if (kind === 'prop' && !currentTag.includes('-')) {
1911
+ assertNotFunctionReflectedActionProp(val, name, currentTag);
1912
+ }
1487
1913
  buf = buf.slice(0, attrStart);
1488
1914
  state = 'in-tag';
1489
1915
  attrName = '';
1490
1916
  } else if (kind === 'bool') {
1917
+ // A boolean binding stringifies nothing, so this never leaked, but
1918
+ // `?action=${fn}` is meaningless in every case (a truthy function
1919
+ // emits a bare `action=""`), and refusing it keeps the rule the docs
1920
+ // state true for every sigil rather than true only when quoted.
1921
+ assertNotFunctionActionAttr(val, name, currentTag);
1491
1922
  buf = buf.slice(0, attrStart);
1492
1923
  if (val) buf += `${name}=""`;
1493
1924
  state = 'in-tag';
1494
1925
  attrName = '';
1495
1926
  } else {
1927
+ // The SAME guard as the buffered renderer above. This is a second,
1928
+ // independent state machine, so it inherits nothing from that one;
1929
+ // a change to the rule has to land in both. Reached only via
1930
+ // `renderToStream(v, { ssr: false })`, which no page render uses, so
1931
+ // this covers the public API surface rather than a page leak.
1932
+ assertNotFunctionActionAttr(val, attrName, currentTag);
1496
1933
  buf += `"${escapeAttr(String(val ?? ''))}"`;
1497
1934
  state = 'in-tag';
1498
1935
  attrName = '';
1499
1936
  }
1500
1937
  } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
1938
+ assertNotFunctionActionAttr(val, attrName, currentTag);
1501
1939
  buf += escapeAttr(String(val ?? ''));
1502
1940
  }
1503
1941
  }