@webjsdev/core 0.7.48 → 0.7.50

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.
@@ -750,11 +750,10 @@ function buildFormActionRecord(el, onEl, parts) {
750
750
  * record.
751
751
  *
752
752
  * FORMS FIRST, submitters second, regardless of the order the records were
753
- * collected in. A submitter asks whether its enclosing form is bound, and the
754
- * cheapest true answer is the identity field the form's own reconcile just
755
- * inserted. Document order gets this right for a form and a button in the SAME
756
- * template, but not for a `<form>` whose submitter arrives through a nested
757
- * template, so the ordering is made explicit rather than relied upon.
753
+ * collected in. Nothing reads an enclosing form's boundness any more (#1307
754
+ * made a bound submitter self-sufficient), but the ordering still makes a
755
+ * form's RELEASE run before its submitters reconcile, so it is kept rather
756
+ * than churned. See the note in the body.
758
757
  *
759
758
  * @param {FormActionRecord[] | null} formActions
760
759
  * @param {BoundPart[]} bound
@@ -16,6 +16,8 @@ import { isSuspense } from './suspense.js';
16
16
  import { unsafeHTML, isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
17
17
  import { stringify, parse } from './serialize.js';
18
18
  import { cspNonce } from './csp-nonce.js';
19
+ import { readAttributeValue, resolveAttributeProperty } from './attribute-reader.js';
20
+ import NAMED_ENTITIES, { LEGACY_NAMES } from './html-entities.js';
19
21
 
20
22
  /**
21
23
  * Render a TemplateResult (or any renderable value) to an HTML string.
@@ -180,23 +182,6 @@ async function renderTemplate(tr, ctx) {
180
182
  let pendingPropAttrs = [];
181
183
  /** @type {string[]} */
182
184
  let pendingSubmitterProps = [];
183
- // Whether the tag stream is currently inside a form that BOUND an action
184
- // (#1207), as THIS scan can see it. Three states, and the third is the point:
185
- // 'bound' an enclosing <form> opened here and bound an action
186
- // 'unbound' an enclosing <form> opened here and bound nothing
187
- // 'none' there is conclusively no enclosing <form>
188
- // 'unknown' there may be one, but this scan cannot see it
189
- //
190
- // The last two look alike and must not be merged, which is what a boolean did.
191
- // A component's template is rendered by a SEPARATE pass (`injectDSD` calls
192
- // `render` on it), so a `<button formaction=${fn}>` inside a component inside
193
- // a bound form read as "no form", was refused, and in production vanished
194
- // from a page that still returned 200. That pass now says 'unknown' and the
195
- // boundness question is skipped, exactly as the client skips it when it
196
- // cannot reach the form, so both renderers are best effort in the same place
197
- // and for the same reason. A top-level scan that simply contains no form
198
- // stays 'none' and is still refused, because there the answer IS known.
199
- //
200
185
  let isCloseTag = false;
201
186
 
202
187
  // A bound `action=${fn}` is committed at its hole, but the edits it implies
@@ -400,12 +385,15 @@ async function renderTemplate(tr, ctx) {
400
385
  // page-level `.prop` on a native element could have set the
401
386
  // property to begin with.
402
387
  out = out.slice(0, attrStart);
403
- // `<webjs-suspense .fallback=${html`...`}>` (#471). A TemplateResult
404
- // is not serializer-safe (the normal data-webjs-prop-* path would
405
- // drop it) and a normal custom-element prop applies only at
406
- // hydration, too late for the streaming placeholder. So render the
407
- // fallback to HTML now and carry it as data-webjs-fallback, which the
408
- // injectDSD streaming pre-pass reads as the boundary placeholder.
388
+ // `<webjs-suspense .fallback=${html`...`}>` (#471). This element is
389
+ // defined only in the browser, so the injectDSD walk skips it
390
+ // (`lookup(tag)` finds no class) and no server-side instance runs
391
+ // consumePropAttrs. A normal data-webjs-prop-* binding would then
392
+ // land at connectedCallback, too late for the streaming placeholder.
393
+ // So render the fallback to HTML now and carry it as
394
+ // data-webjs-fallback, which the injectDSD streaming pre-pass reads
395
+ // as the boundary placeholder. (The value itself would serialize
396
+ // fine: a TemplateResult is a plain {strings, values} object.)
409
397
  if (currentTag === 'webjs-suspense' && name === 'fallback') {
410
398
  const fbHtml = await render(val, ctx);
411
399
  out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
@@ -480,14 +468,13 @@ async function renderTemplate(tr, ctx) {
480
468
  // #1207: the submitter binding. The identity replaces the
481
469
  // `formaction=` hole IN PLACE with the button's own name/value
482
470
  // pair, the one channel a browser submits for the pressed button
483
- // alone. No `formaction` url is emitted, so the submission still
484
- // targets the page and the form-level identity is simply overridden
485
- // by this later entry.
471
+ // alone. No `formaction` url is emitted, so the submission targets
472
+ // whatever the FORM targets, and a form-level identity is simply
473
+ // overridden by this later entry.
486
474
  //
487
475
  // Refused here rather than at the `>` only where the answer cannot
488
- // change later: boundness of the ENCLOSING form is already decided
489
- // (its start tag is emitted), and an attribute written BEFORE the
490
- // hole is already in `out`. Everything else waits for the close,
476
+ // change later: an attribute written BEFORE the hole is already in
477
+ // `out`. Everything else waits for the close,
491
478
  // where `assertSubmitterStartTag` sees the whole tag.
492
479
  // A second binding hole on this same tag, refused here so the
493
480
  // author gets the duplicate message rather than a confusing
@@ -1032,14 +1019,12 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
1032
1019
  // Render the template to HTML. injectDSD recurses on the result so
1033
1020
  // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
1034
1021
  // get their own DSD pass.
1035
- // 'unknown' for the form scope (#1207): this is a SEPARATE render pass
1036
- // over one component's own template, driven by walking the already-emitted
1037
- // HTML, so it has no idea whether the host tag sits inside a bound
1038
- // `<form>`. Passing the default 'none' claimed there was no form at all,
1039
- // which refused a perfectly good `<button formaction=${fn}>` in a
1040
- // component inside a bound form and, because component SSR errors are
1041
- // isolated, made the button vanish from a page that still returned 200.
1042
- const rawInner = await render(tpl, ctx, 'unknown');
1022
+ // This is a SEPARATE render pass over one component's own template,
1023
+ // driven by walking the already-emitted HTML, so it has no idea whether
1024
+ // the host tag sits inside a `<form>`. It does not need to: a bound
1025
+ // submitter carries its whole submission (#1307), so nothing here asks
1026
+ // about an enclosing form.
1027
+ const rawInner = await render(tpl, ctx);
1043
1028
 
1044
1029
  if (isShadow) {
1045
1030
  // Shadow DOM: native <slot> stays as-is in the DSD template. The
@@ -1276,7 +1261,7 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
1276
1261
  result += rest.slice(0, openStart);
1277
1262
  const attrs = m[1] || '';
1278
1263
  const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
1279
- const fallbackHtml = fbMatch ? unescapeAttr(fbMatch[1]) : '';
1264
+ const fallbackHtml = fbMatch ? decodeAttrEntities(fbMatch[1]) : '';
1280
1265
 
1281
1266
  // Pass the FULL-input ranges shifted into `rest` coordinates rather than
1282
1267
  // letting the helper re-tokenize the suffix. `rest` can begin mid-comment
@@ -1647,7 +1632,7 @@ function parseAttrs(attrStr) {
1647
1632
  function seedServerAttrs(instance, attrs) {
1648
1633
  if (!instance || typeof instance.setAttribute !== 'function') return;
1649
1634
  for (const [name, raw] of Object.entries(attrs)) {
1650
- instance.setAttribute(name, unescapeAttr(raw));
1635
+ instance.setAttribute(name, decodeAttrEntities(raw));
1651
1636
  }
1652
1637
  }
1653
1638
 
@@ -1698,39 +1683,41 @@ function appendReflectedAttrs(opening, instance, presentAttrNames) {
1698
1683
  * based on its static `properties` declaration.
1699
1684
  */
1700
1685
  function applyAttrsToInstance(instance, attrs, Cls) {
1701
- const props = Cls.properties || {};
1702
- for (const [key, raw] of Object.entries(attrs)) {
1703
- // Resolve the source attribute name to its property: a custom `attribute`
1704
- // option wins, else the kebab-cased property name, else the camelCase of
1705
- // the attribute (the common case). Mirrors the client attributeChangedCallback
1706
- // so a custom-attribute prop gets the right value in the SSR'd first paint.
1707
- let propName, rawDef;
1708
- for (const [k, decl] of Object.entries(props)) {
1709
- const d = typeof decl === 'object' ? decl : { type: decl };
1710
- if ((d.attribute || hyphenate(k)) === key) { propName = k; rawDef = decl; break; }
1711
- }
1712
- if (rawDef === undefined) {
1713
- rawDef = props[key] || props[camelCase(key)];
1714
- propName = props[key] ? key : camelCase(key);
1715
- }
1716
- if (!rawDef) {
1717
- instance[propName] = raw;
1718
- continue;
1719
- }
1720
- // The factory accepts a bare constructor shorthand (`{ expanded: Boolean }`)
1721
- // alongside the long form (`{ expanded: { type: Boolean } }`); normalize so
1722
- // the type-based coercion below sees a `{ type }` object either way.
1723
- const def = typeof rawDef === 'object' ? rawDef : { type: rawDef };
1724
- if (def.type === Number) instance[propName] = Number(raw);
1725
- else if (def.type === Boolean) instance[propName] = raw !== 'false';
1726
- else if (def.type === Object || def.type === Array) {
1727
- // `raw` is the entity-encoded attribute text (parseAttrs returns the
1728
- // literal characters between the quotes), so decode the HTML entities
1729
- // before JSON.parse. A JSON attribute carries `&quot;` for every `"`;
1730
- // parsing it raw throws and would silently fall back to the string,
1731
- // leaving an Object/Array prop holding a string at SSR.
1732
- try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = raw; }
1733
- } else instance[propName] = raw;
1686
+ for (const [sourceName, sourceValue] of Object.entries(attrs)) {
1687
+ // The browser LOWERCASES every attribute name while parsing an HTML
1688
+ // document, so `cfgData="x"` reaches the client reader as `cfgdata` and a
1689
+ // camelCase name can never match anything in `observedAttributes`.
1690
+ // Resolving the source case here made SSR read a name the platform cannot
1691
+ // deliver, which is the divergence, not the fix (#1341).
1692
+ const resolved = resolveAttributeProperty(Cls, sourceName.toLowerCase());
1693
+ // An attribute mapping to no attribute-backed property is IGNORED, exactly
1694
+ // as `attributeChangedCallback` ignores it and as lit's reader does. This
1695
+ // used to assign it as an instance property (`instance[propName] = raw`),
1696
+ // which no browser upgrade ever reproduces, and which on a real
1697
+ // HTMLElement could mutate DOM state through `id` / `hidden` / `slot`.
1698
+ // `seedServerAttrs` has already applied every source attribute properly,
1699
+ // so nothing needs the copy (#1341).
1700
+ if (resolved === undefined) continue;
1701
+ const { propName, def } = resolved;
1702
+ // A browser decodes every character reference BEFORE any reader sees the
1703
+ // value, so decode once here, for every branch, and hand the shared reader
1704
+ // an already-decoded string exactly as the DOM hands the client one. It
1705
+ // used to be a `decode` argument the reader applied on the JSON and
1706
+ // converter branches alone, which left a `String`-typed prop holding the
1707
+ // raw source text while `getAttribute()` on the client returned the decoded
1708
+ // one (#1341).
1709
+ //
1710
+ // One reader for both sides (#1340): `readAttributeValue` in
1711
+ // `attribute-reader.js` is the same function `attributeChangedCallback` in
1712
+ // `component.js` calls, so a custom `converter.fromAttribute` runs here
1713
+ // too, ahead of type coercion, and the #1253 unparseable-JSON fallback is
1714
+ // shared rather than mirrored.
1715
+ //
1716
+ // A converter that THROWS is not caught here. It lands in the
1717
+ // per-component error isolation below, which is deliberate: an author who
1718
+ // supplies a converter owns the read, the same rule `_reflectAttribute`
1719
+ // states for `toAttribute`. See the comment on `readAttributeValue`.
1720
+ instance[propName] = readAttributeValue(def, decodeAttrEntities(sourceValue));
1734
1721
  }
1735
1722
  }
1736
1723
 
@@ -1739,11 +1726,6 @@ function camelCase(s) {
1739
1726
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1740
1727
  }
1741
1728
 
1742
- /** Kebab-case a property name for its default HTML attribute (matches component.js). @param {string} s */
1743
- function hyphenate(s) {
1744
- return s.replace(/([A-Z])/g, '-$1').toLowerCase();
1745
- }
1746
-
1747
1729
  /**
1748
1730
  * Inverse of camelCase. `userName` -> `user-name`, `userID` -> `user-i-d`.
1749
1731
  * Used to serialize property-binding names into HTML attribute names,
@@ -1756,20 +1738,142 @@ function kebabCase(s) {
1756
1738
  return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
1757
1739
  }
1758
1740
 
1741
+ /** windows-1252 mappings the HTML tokenizer applies to the C1 range. */
1742
+ const C1_REPLACEMENTS = {
1743
+ 0x80: 0x20ac, 0x82: 0x201a, 0x83: 0x0192, 0x84: 0x201e, 0x85: 0x2026,
1744
+ 0x86: 0x2020, 0x87: 0x2021, 0x88: 0x02c6, 0x89: 0x2030, 0x8a: 0x0160,
1745
+ 0x8b: 0x2039, 0x8c: 0x0152, 0x8e: 0x017d, 0x91: 0x2018, 0x92: 0x2019,
1746
+ 0x93: 0x201c, 0x94: 0x201d, 0x95: 0x2022, 0x96: 0x2013, 0x97: 0x2014,
1747
+ 0x98: 0x02dc, 0x99: 0x2122, 0x9a: 0x0161, 0x9b: 0x203a, 0x9c: 0x0153,
1748
+ 0x9e: 0x017e, 0x9f: 0x0178,
1749
+ };
1750
+
1751
+ // The trailing `;` is OPTIONAL on the named arm, because a browser decodes a
1752
+ // legacy semicolon-less reference too. The callback decides which of the two
1753
+ // forms it has; see `decodeNamed`.
1754
+ const CHAR_REF = /&(?:#(\d+);?|#[xX]([0-9a-fA-F]+);?|([a-zA-Z][a-zA-Z0-9]*)(;?))/g;
1755
+
1756
+ // A Map, not the imported object, so a name that collides with something on
1757
+ // `Object.prototype` misses instead of returning a function. See `decodeNamed`.
1758
+ const NAMED = new Map(Object.entries(NAMED_ENTITIES));
1759
+ const LEGACY = new Set(LEGACY_NAMES);
1760
+
1759
1761
  /**
1760
- * Reverse `escapeAttr` on a server-side attribute value. Needed
1761
- * because `parseAttrs` returns the literal characters between the
1762
- * quote marks; HTML entities are not decoded by the regex. The
1763
- * browser handles this automatically, so client-side reads via
1764
- * `getAttribute()` do not need the same step.
1762
+ * Decode HTML character references in an attribute value.
1763
+ *
1764
+ * `parseAttrs` hands back the literal characters between the quote marks, so
1765
+ * nothing has decoded them yet, while a browser decodes EVERY reference before
1766
+ * any reader sees the value. This closes that gap (#1341): the full WHATWG
1767
+ * named table plus decimal and hexadecimal numeric references, with the
1768
+ * tokenizer's own numeric fix-ups (null, a surrogate, and anything past
1769
+ * U+10FFFF become U+FFFD; the C1 range maps through windows-1252). It replaced
1770
+ * a three-entity `unescapeAttr`, which left a `String`-typed prop undecoded
1771
+ * entirely and turned `&lt;script&gt;` into the half-decoded `<script&gt;`.
1772
+ *
1773
+ * SINGLE PASS on purpose. A replacement is never rescanned, so `&amp;lt;`
1774
+ * decodes to the literal `&lt;` and never to `<`. The old function got that
1775
+ * from replacing `&amp;` last, which does not generalise past three entities.
1776
+ *
1777
+ * The 106 legacy semicolon-LESS names are covered too, because a browser really
1778
+ * does decode them and measurably: Chromium, Firefox, and WebKit all hand a
1779
+ * reader U+00A0 for `s="&nbsp"`. Leaving them literal would have been a value
1780
+ * divergence of exactly the kind this function exists to remove, not a
1781
+ * harmless non-goal. The rule that governs them, in an ATTRIBUTE value, is a
1782
+ * one-character LOOKAHEAD rather than deep tokenizer state, which is why it is
1783
+ * implementable here. `decodeNamed` carries the rule and the reason it takes
1784
+ * the shape it does; the short version is that `&nbsp` at the end of a value
1785
+ * decodes while `&nbspx`, `&nbsp=x`, and `&notin` stay literal, all three
1786
+ * verified against the three engines.
1787
+ *
1788
+ * The same function also decodes `data-webjs-fallback`, which is MARKUP, where
1789
+ * the tokenizer applies no such carve-out. Using the attribute rule there is
1790
+ * deliberate and strictly conservative: that payload is written by
1791
+ * `escapeAttr`, which emits only `&amp;` / `&quot;` / `&lt;`, so every
1792
+ * reference in it is semicolon-terminated and never reaches this path.
1765
1793
  *
1766
1794
  * @param {string} s
1795
+ * @returns {string}
1796
+ */
1797
+ function decodeAttrEntities(s) {
1798
+ // Load bearing, not a micro-optimisation: skipping the scan for the common
1799
+ // no-`&` value is what makes this cheaper per attribute than the three
1800
+ // chained `replace` calls it replaced, which paid for three passes always.
1801
+ if (s.indexOf('&') === -1) return s;
1802
+ return s.replace(CHAR_REF, (match, dec, hex, name, semi, offset) => {
1803
+ if (dec !== undefined) return fromCodePoint(parseInt(dec, 10));
1804
+ if (hex !== undefined) return fromCodePoint(parseInt(hex, 16));
1805
+ return decodeNamed(match, name, semi === ';', s[offset + match.length]);
1806
+ });
1807
+ }
1808
+
1809
+ /**
1810
+ * Resolve one named reference, semicolon-terminated or legacy.
1811
+ *
1812
+ * The table is read through a `Map` rather than by indexing the imported
1813
+ * object. An object-literal lookup resolves through `Object.prototype`, so
1814
+ * `&constructor;` / `&toString;` / `&hasOwnProperty;` and four more returned a
1815
+ * FUNCTION instead of `undefined` and threw on the spread in
1816
+ * `codePointsToString`, a path `seedServerAttrs` reaches for every attribute of
1817
+ * every custom element, which rendered the whole component as an SSR error box.
1818
+ * A browser leaves those literal, since they are not named references, so the
1819
+ * throw was a divergence of exactly the kind this file exists to remove. A
1820
+ * `Map` has no prototype chain to fall through, which closes the shape rather
1821
+ * than guarding one call site.
1822
+ *
1823
+ * WHY THERE IS NO LONGEST-PREFIX LOOP. The tokenizer consumes the longest name
1824
+ * in the table, so `&notin` is `&not` followed by `in`. Here that always
1825
+ * collapses to the whole name: `CHAR_REF` captures `[a-zA-Z][a-zA-Z0-9]*`, so a
1826
+ * prefix SHORTER than the name is by construction followed by an ASCII
1827
+ * alphanumeric, which is precisely when the attribute carve-out declines to
1828
+ * decode. A loop over shorter prefixes could therefore only ever return
1829
+ * `match`, which is what falling through to the end already does. `&notin`
1830
+ * stays literal either way, verified against the three engines, though note it
1831
+ * gets there by not being a legacy name rather than by the carve-out.
1832
+ *
1833
+ * That same greediness means an ASCII alphanumeric character can never follow
1834
+ * the match: whatever follows is by construction not `[a-zA-Z0-9]`, or the
1835
+ * capture would have eaten it. So only the `=` check is needed for the
1836
+ * attribute carve-out. `&nbspx` is literal because `nbspx` is not a legacy
1837
+ * name, NOT because of the lookahead; `&nbsp=x` is the shape the lookahead
1838
+ * actually decides.
1839
+ *
1840
+ * @param {string} match the whole matched reference, returned unchanged when nothing decodes
1841
+ * @param {string} name the name, with no `&` and no `;`
1842
+ * @param {boolean} hadSemi
1843
+ * @param {string|undefined} nextChar the character after the match, if any
1844
+ * @returns {string}
1845
+ */
1846
+ function decodeNamed(match, name, hadSemi, nextChar) {
1847
+ if (hadSemi) {
1848
+ const cp = NAMED.get(name);
1849
+ return cp === undefined ? match : codePointsToString(cp);
1850
+ }
1851
+ if (!LEGACY.has(name)) return match;
1852
+ // The attribute carve-out: a legacy name decodes only when what follows is
1853
+ // neither `=` nor an ASCII alphanumeric. Nothing following at all (the end of
1854
+ // the value) decodes, which is the common `s="&nbsp"` shape. Because CHAR_REF
1855
+ // captures greedily, what follows is never an ASCII alphanumeric, so only `=`
1856
+ // needs to be checked here.
1857
+ if (nextChar === '=') return match;
1858
+ // Every legacy name is in the table under the same name, asserted by a test
1859
+ // rather than left to trust, so this cannot miss.
1860
+ return codePointsToString(NAMED.get(name));
1861
+ }
1862
+
1863
+ /** @param {number|number[]} cp */
1864
+ function codePointsToString(cp) {
1865
+ return typeof cp === 'number' ? String.fromCodePoint(cp) : String.fromCodePoint(...cp);
1866
+ }
1867
+
1868
+ /**
1869
+ * The tokenizer's numeric character reference fix-ups.
1870
+ * @param {number} n
1871
+ * @returns {string}
1767
1872
  */
1768
- function unescapeAttr(s) {
1769
- return s
1770
- .replace(/&lt;/g, '<')
1771
- .replace(/&quot;/g, '"')
1772
- .replace(/&amp;/g, '&');
1873
+ function fromCodePoint(n) {
1874
+ if (n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff)) return '\uFFFD';
1875
+ if (C1_REPLACEMENTS[n] !== undefined) return String.fromCodePoint(C1_REPLACEMENTS[n]);
1876
+ return String.fromCodePoint(n);
1773
1877
  }
1774
1878
 
1775
1879
  /**
@@ -1788,7 +1892,7 @@ function consumePropAttrs(attrs) {
1788
1892
  if (!key.startsWith('data-webjs-prop-')) continue;
1789
1893
  const propName = camelCase(key.slice('data-webjs-prop-'.length));
1790
1894
  try {
1791
- props[propName] = parse(unescapeAttr(attrs[key]));
1895
+ props[propName] = parse(decodeAttrEntities(attrs[key]));
1792
1896
  } catch {
1793
1897
  // Malformed payload. Skip silently so the rest of the component
1794
1898
  // can still render. The client-side hydration will also try and
@@ -1978,8 +2082,6 @@ async function streamTemplate(tr, ctx, controller) {
1978
2082
  let pendingSubmitterProps = [];
1979
2083
  /** @type {string | null} */
1980
2084
  let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
1981
- // See the buffered machine: seeded from the caller so a nested template
1982
- // rendered into a bound form's children knows it is inside one.
1983
2085
  let isCloseTag = false;
1984
2086
 
1985
2087
  // See the buffered machine for why this runs at the `>` rather than at the
@@ -859,9 +859,13 @@ function onSubmit(e) {
859
859
  const submitter = /** @type {HTMLElement | null} */ (e.submitter ?? null);
860
860
  if (submitter && submitter.hasAttribute('data-no-router')) return;
861
861
 
862
- const target = (submitter && submitter.getAttribute('formtarget'))
863
- || form.getAttribute('target')
864
- || '';
862
+ // Presence, not truthiness, exactly as `getSubmitAction` resolves the action
863
+ // (#1322). The form-submission algorithm asks whether the submitter HAS a
864
+ // `formtarget`, so a present-but-empty one overrides the form and then means
865
+ // the current browsing context, per the rules for choosing a navigable.
866
+ const target = (submitter && submitter.hasAttribute('formtarget'))
867
+ ? (submitter.getAttribute('formtarget') || '')
868
+ : (form.getAttribute('target') || '');
865
869
  if (target && target !== '_self') return;
866
870
 
867
871
  const method = getSubmitMethod(form, submitter);
@@ -911,14 +915,21 @@ function onSubmit(e) {
911
915
  * Method resolution: submitter's `formmethod` wins over form's `method`.
912
916
  * Returns lowercase.
913
917
  *
918
+ * Resolved on PRESENCE, not truthiness (#1322), the same way `getSubmitAction`
919
+ * below resolves the action. The form-submission algorithm reads the
920
+ * submitter's `formmethod` if the submitter HAS one, so a present-but-empty
921
+ * `formmethod=""` overrides the form and then falls to its own invalid-value
922
+ * default, GET. Resolving with `||` instead sent a multipart POST for a button
923
+ * every engine submits as `GET`, which is a JS-on versus JS-off divergence.
924
+ *
914
925
  * @param {HTMLFormElement} form
915
926
  * @param {HTMLElement | null} submitter
916
927
  */
917
928
  function getSubmitMethod(form, submitter) {
918
- const v = (submitter && submitter.getAttribute('formmethod'))
919
- || form.getAttribute('method')
920
- || 'get';
921
- return v.toLowerCase();
929
+ const v = (submitter && submitter.hasAttribute('formmethod'))
930
+ ? (submitter.getAttribute('formmethod') || '')
931
+ : (form.getAttribute('method') || '');
932
+ return (v || 'get').toLowerCase();
922
933
  }
923
934
 
924
935
  /**
@@ -965,12 +976,19 @@ function normalizeEnctype(raw) {
965
976
  * `enctype`, exactly as `getSubmitMethod` resolves the method (#1307). Turbo
966
977
  * resolves it the same way, in `core/drive/form_submission.js`.
967
978
  *
979
+ * On PRESENCE, not truthiness (#1322), for the reason spelled out on
980
+ * `getSubmitMethod`: a present-but-empty `formenctype=""` overrides the form
981
+ * and normalizes to urlencoded, its own invalid-value default, so a button on
982
+ * a multipart form sends urlencoded with JS on exactly as it does with JS off.
983
+ *
968
984
  * @param {HTMLFormElement} form
969
985
  * @param {HTMLElement | null} submitter
970
986
  */
971
987
  function getSubmitEnctype(form, submitter) {
972
988
  return normalizeEnctype(
973
- (submitter && submitter.getAttribute('formenctype')) || form.getAttribute('enctype'),
989
+ (submitter && submitter.hasAttribute('formenctype'))
990
+ ? submitter.getAttribute('formenctype')
991
+ : form.getAttribute('enctype'),
974
992
  );
975
993
  }
976
994
 
@@ -1537,7 +1555,7 @@ function setNavigating(on) {
1537
1555
  * @returns {Map<string, { routeKey: string, start: Comment, end: Comment }> | null}
1538
1556
  * The boundary map, or null when the tree's boundaries are malformed.
1539
1557
  */
1540
- export function collectBoundaries(root) {
1558
+ function collectBoundaries(root) {
1541
1559
  /** @type {Map<string, { routeKey: string, start: Comment, end: Comment }>} */
1542
1560
  const out = new Map();
1543
1561
  /** @type {{ segment: string, routeKey: string, start: Comment }[]} */
@@ -1654,7 +1672,7 @@ export function collectBoundaries(root) {
1654
1672
  * live: { routeKey: string, start: Comment, end: Comment },
1655
1673
  * incoming: { routeKey: string, start: Comment, end: Comment } } | null}
1656
1674
  */
1657
- export function planBoundarySwap(here, there) {
1675
+ function planBoundarySwap(here, there) {
1658
1676
  // Shared segments, shallowest first (a nested path prefix is shorter).
1659
1677
  const shared = [...here.keys()].filter((s) => there.has(s)).sort((a, b) => a.length - b.length);
1660
1678
  if (shared.length === 0) return null;
@@ -10,8 +10,19 @@
10
10
  * This object is read key by key, mostly by the server and for a few keys
11
11
  * by the CLI. Without a type or schema a typo'd key (e.g. `redirect` for
12
12
  * `redirects`) was silently dropped and the feature stayed at its default
13
- * with no diagnostic. This type plus the published JSON Schema close that
14
- * gap.
13
+ * with no diagnostic. Three things close that gap now, at three different
14
+ * moments: this type while an agent or a human authors the block, the
15
+ * published JSON Schema in an editor via the scaffold's `.vscode` `$ref`,
16
+ * and `webjs-config-validate.js` at BOOT (#1300), which runs that same
17
+ * schema from `createRequestHandler` and warns once, naming every unknown
18
+ * top-level key, every bad `enum` value, and every wrong-typed `boolean` /
19
+ * `integer` (9 of the 17 keys). It does NOT type-check the other 8
20
+ * (`headers`, `redirects`, `basePath`, `allowedOrigins`, `csp`, `dev`,
21
+ * `start`, `doctor`), and it does not descend into a nested object, so a
22
+ * misspelling inside `dev` or `start` is not reported either. Whether
23
+ * anything else notices what this check passes over is up to the
24
+ * individual reader. The boot check warns and never throws, so a typo
25
+ * costs one feature its setting rather than the whole app its boot.
15
26
  *
16
27
  * LOCKSTEP: this file, the JSON Schema at
17
28
  * packages/server/webjs-config.schema.json, and the reader functions MUST
@@ -81,7 +81,8 @@ export function connectWS(url, opts = {}) {
81
81
 
82
82
  return {
83
83
  /** Send a message. Objects are JSON-stringified; strings and binary pass through.
84
- * If the socket isn't open yet, the message is queued until it is. */
84
+ * If the socket isn't open yet, the message is queued until it is.
85
+ * @param {string | ArrayBuffer | ArrayBufferView | object} data */
85
86
  send(data) {
86
87
  const payload =
87
88
  typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)
@@ -90,7 +91,9 @@ export function connectWS(url, opts = {}) {
90
91
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(/** @type any */ (payload));
91
92
  else queue.push(payload);
92
93
  },
93
- /** Permanently close the socket. Disables reconnect. */
94
+ /** Permanently close the socket. Disables reconnect.
95
+ * @param {number} [code]
96
+ * @param {string} [reason] */
94
97
  close(code, reason) {
95
98
  stopped = true;
96
99
  try { ws?.close(code, reason); } catch {}