@webjsdev/core 0.7.48 → 0.7.49

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/index-browser.js CHANGED
@@ -8,9 +8,10 @@
8
8
  * Stripped:
9
9
  * - `renderToString`, `renderToStream` (server SSR pipeline; reach
10
10
  * for `@webjsdev/core/server` if you need them in a Node test).
11
- * - `setCspNonceProvider` (server-side wiring called by
12
- * `@webjsdev/server`'s context module; `cspNonce()` stays
13
- * because layouts can call it).
11
+ * - `setCspNonceProvider`, `setAssetUrlProvider` and
12
+ * `setFormActionResolver` (server-side wiring called by
13
+ * `@webjsdev/server`; the reader halves stay, because a layout
14
+ * can call `cspNonce()` / `asset()`).
14
15
  *
15
16
  * The framework's own SSR pipeline runs on Node and resolves the
16
17
  * package via the package.json `"default"` condition, which still
@@ -52,7 +53,14 @@ export {
52
53
  // the re-export still also evaluates the module, so the two are belt-and-
53
54
  // suspenders, not a double-enable (ESM evaluates once, enable is idempotent).
54
55
  import './src/router-client.js';
55
- export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
56
+ // `loadFrame` is here (and in `./index.js`, keeping the two symmetric) because
57
+ // `@webjsdev/core/client-router`'s `default` condition points at THIS bundle, so
58
+ // in dist mode the subpath resolves here while its published `types`
59
+ // (`src/router-client.d.ts`) declare all five public router functions. Without
60
+ // it, `import { loadFrame } from '@webjsdev/core/client-router'` type-checks,
61
+ // works in dev (where the subpath maps to `src/router-client.js`) and is
62
+ // `undefined` in production.
63
+ export { enableClientRouter, disableClientRouter, navigate, revalidate, loadFrame } from './src/router-client.js';
56
64
  export { WebjsFrame } from './src/webjs-frame.js';
57
65
  export { WebjsStream, renderStream } from './src/webjs-stream.js';
58
66
 
package/index.d.ts CHANGED
@@ -81,7 +81,7 @@ export { repeat, isRepeat } from './src/repeat.js';
81
81
  export { Suspense, isSuspense } from './src/suspense.js';
82
82
  export { connectWS } from './src/websocket-client.js';
83
83
  export { richFetch } from './src/rich-fetch.js';
84
- export { enableClientRouter, disableClientRouter, revalidate } from './src/router-client.js';
84
+ export { enableClientRouter, disableClientRouter, revalidate, loadFrame } from './src/router-client.js';
85
85
 
86
86
  // `navigate` is typed against the generated `Route` union (#258) rather than
87
87
  // the JSDoc `string`. Until an app runs `webjs types`, `Route` resolves to
package/index.js CHANGED
@@ -28,7 +28,7 @@ export {
28
28
  stringify, parse,
29
29
  serialize, deserialize,
30
30
  } from './src/serialize.js';
31
- export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
31
+ export { enableClientRouter, disableClientRouter, navigate, revalidate, loadFrame } from './src/router-client.js';
32
32
  export { WebjsFrame } from './src/webjs-frame.js';
33
33
  export { WebjsStream, renderStream } from './src/webjs-stream.js';
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.48",
3
+ "version": "0.7.49",
4
4
  "type": "module",
5
5
  "description": "The runtime for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Ships the html and css template tags, the WebComponent base class, signals, directives, and the isomorphic renderers.",
6
6
  "types": "./index.d.ts",
package/src/component.js CHANGED
@@ -157,6 +157,42 @@ function warnFunctionReflection(host, propName, attrName) {
157
157
  );
158
158
  }
159
159
 
160
+ /**
161
+ * Warn that a value with no JSON representation was dropped rather than
162
+ * reflected (#1253).
163
+ *
164
+ * `JSON.stringify` throws for three reasons an app hits in practice: a cycle,
165
+ * a `BigInt`, and an author `toJSON()` that throws. All three mean the same
166
+ * thing here, that there is no string to put in the attribute, so all three
167
+ * get the same answer as a function does.
168
+ *
169
+ * The caught message IS included, unlike `warnFunctionReflection`, which
170
+ * withholds its value on purpose. A function's string form is its source,
171
+ * which is the leak that guard exists to prevent; `JSON.stringify`'s own
172
+ * message names the property path rather than a value, and the sibling
173
+ * `.prop=${val}` SSR drop in `render-server.js` already ends the same way.
174
+ *
175
+ * UNCONDITIONAL, for the reason recorded on `warnFunctionReflection` above: a
176
+ * `NODE_ENV` gate is folded to a constant by the dist build, which would make
177
+ * the SSR half unreachable in every published build, and the SSR half is where
178
+ * the component silently vanishes.
179
+ *
180
+ * @param {{ constructor: unknown, tagName?: string }} host
181
+ * @param {string} propName
182
+ * @param {string} attrName
183
+ * @param {string} [detail] the message `JSON.stringify` threw
184
+ */
185
+ function warnUnserializableReflection(host, propName, attrName, detail) {
186
+ if (typeof console === 'undefined' || !console.warn) return;
187
+ const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown';
188
+ console.warn(
189
+ `[webjs] reflect:true property "${propName}" on <${tag}> holds a value `
190
+ + `JSON.stringify cannot serialize (a cycle, a BigInt, or a throwing `
191
+ + `toJSON), so it has no HTML attribute representation. Removing `
192
+ + `"${attrName}" instead. Detail: ${detail}`
193
+ );
194
+ }
195
+
160
196
  /**
161
197
  * A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
162
198
  * while staying JSDoc-only and no-build.
@@ -745,12 +781,40 @@ class WebComponentBase extends Base {
745
781
  } else if (value == null) {
746
782
  this.removeAttribute(attrName);
747
783
  } else if (decl.type === Object || decl.type === Array) {
784
+ // One rule about an unserializable reflected value, in two halves.
785
+ //
748
786
  // A JSON-typed prop CARRYING a function is safe and stays whole:
749
787
  // `JSON.stringify` drops a function to `null` in an array and omits
750
788
  // the key in an object, so `[1, 2, fn]` serializes to `[1,2,null]`
751
789
  // with no source and no data loss. Refusing here would discard the
752
790
  // `1` and the `2` on a path that never leaked.
753
- this.setAttribute(attrName, JSON.stringify(value));
791
+ //
792
+ // A value `JSON.stringify` cannot serialize AT ALL is the other half
793
+ // (#1253): a cycle, a `BigInt`, or an author `toJSON()` that throws.
794
+ // The throw used to escape reflection entirely, so a property
795
+ // assignment threw from the setter, a client upgrade threw out of
796
+ // `connectedCallback` before the first render, and an SSR render was
797
+ // swallowed by per-component isolation, which surfaces an error box in
798
+ // dev and emits the component EMPTY at a 200 in PRODUCTION, where the
799
+ // cause is only in the server log. There is no string to put in the
800
+ // attribute, so the
801
+ // attribute goes, exactly as it does for a function. The `catch` IS
802
+ // the detection: `JSON.stringify` already walks the value, so its own
803
+ // failure covers all three causes, where a cycle-only pre-walk would
804
+ // miss the other two and pay a full traversal per reflection.
805
+ let serialized;
806
+ let serializable = true;
807
+ try {
808
+ serialized = JSON.stringify(value);
809
+ } catch (e) {
810
+ serializable = false;
811
+ this.removeAttribute(attrName);
812
+ warnUnserializableReflection(this, propName, attrName, e && e.message);
813
+ }
814
+ // Outside the `try` on purpose, so a genuine `setAttribute` failure
815
+ // (an invalid attribute name) still surfaces instead of being folded
816
+ // into the unserializable path.
817
+ if (serializable) this.setAttribute(attrName, serialized);
754
818
  } else if (carriesFunction(value)) {
755
819
  // The string fall-through is the one place a CARRIED function still
756
820
  // leaks. `String([fn])` is `Array.prototype.join`, which runs
@@ -1103,7 +1167,35 @@ class WebComponentBase extends Base {
1103
1167
  } else if (def.type === Boolean) {
1104
1168
  v = value != null && value !== 'false';
1105
1169
  } else if (def.type === Object || def.type === Array) {
1106
- try { v = value == null ? null : JSON.parse(value); } catch { v = value; }
1170
+ // An attribute that is not parseable JSON yields `null` rather than the
1171
+ // raw string (#1253), because a STRING is never a valid value for a
1172
+ // property the author declared `Object` or `Array`, whatever put it
1173
+ // there. lit's `defaultConverter.fromAttribute` lands on the same `null`,
1174
+ // for the reason its own comment gives: an element does not complain
1175
+ // about being mis-configured.
1176
+ //
1177
+ // `applyAttrsToInstance` in `render-server.js` is the SSR counterpart of
1178
+ // THIS branch and yields `null` for the same input. They have to agree,
1179
+ // or an element SSRs holding one value and re-renders holding another.
1180
+ // The agreement is about an attribute that is PRESENT and unparseable;
1181
+ // an attribute that was never there does not reach either reader, so
1182
+ // such a prop simply keeps its constructor value.
1183
+ //
1184
+ // That agreement is about this FALLBACK, not a guarantee that the two
1185
+ // readers see the same attributes in the first place. They reach an
1186
+ // attribute by different routes (this one via `observedAttributes` and
1187
+ // the browser's own name-lowercasing, the SSR one by walking the parsed
1188
+ // source tag), and hand-written markup can land in the gaps between
1189
+ // those routes, so an author literal can still be read by one and not
1190
+ // the other. Those gaps predate #1253 and it neither causes nor closes
1191
+ // any of them; no attempt is made here to enumerate them, because every
1192
+ // attempt so far has been incomplete.
1193
+ //
1194
+ // Scoped to the default converter. A prop supplying its own
1195
+ // `converter.fromAttribute` is handled by the arm above and the SSR
1196
+ // reader has no converter arm at all, so the two sides already read such
1197
+ // a prop differently. That gap predates #1253 and is left alone.
1198
+ try { v = value == null ? null : JSON.parse(value); } catch { v = null; }
1107
1199
  } else {
1108
1200
  v = value;
1109
1201
  }
@@ -400,12 +400,15 @@ async function renderTemplate(tr, ctx) {
400
400
  // page-level `.prop` on a native element could have set the
401
401
  // property to begin with.
402
402
  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.
403
+ // `<webjs-suspense .fallback=${html`...`}>` (#471). This element is
404
+ // defined only in the browser, so the injectDSD walk skips it
405
+ // (`lookup(tag)` finds no class) and no server-side instance runs
406
+ // consumePropAttrs. A normal data-webjs-prop-* binding would then
407
+ // land at connectedCallback, too late for the streaming placeholder.
408
+ // So render the fallback to HTML now and carry it as
409
+ // data-webjs-fallback, which the injectDSD streaming pre-pass reads
410
+ // as the boundary placeholder. (The value itself would serialize
411
+ // fine: a TemplateResult is a plain {strings, values} object.)
409
412
  if (currentTag === 'webjs-suspense' && name === 'fallback') {
410
413
  const fbHtml = await render(val, ctx);
411
414
  out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
@@ -1727,9 +1730,37 @@ function applyAttrsToInstance(instance, attrs, Cls) {
1727
1730
  // `raw` is the entity-encoded attribute text (parseAttrs returns the
1728
1731
  // literal characters between the quotes), so decode the HTML entities
1729
1732
  // 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
+ // parsing it raw throws, and the prop then falls to the failure value
1734
+ // below rather than to the object the author wrote.
1735
+ //
1736
+ // An unparseable attribute falls back to `null`, NOT to the raw string
1737
+ // (#1253), matching this branch's counterpart in
1738
+ // `attributeChangedCallback`. They have to agree, or the same
1739
+ // `<my-el cfg="oops">` SSRs holding a string and re-renders holding
1740
+ // something else the moment the element upgrades. `null` is the right
1741
+ // value to agree on: a string is never a valid value for a property the
1742
+ // author declared `Object` or `Array`. This function iterates the
1743
+ // attributes PRESENT on the source tag, so an absent one is not read here
1744
+ // at all and its property keeps whatever the constructor gave it; the
1745
+ // agreement being asserted is about a present, unparseable attribute.
1746
+ //
1747
+ // That agreement is about this FALLBACK, not a guarantee that the two
1748
+ // readers see the same attributes in the first place. This function
1749
+ // walks the parsed source tag while the client goes through
1750
+ // `observedAttributes` and the browser's own name-lowercasing, and
1751
+ // hand-written markup can land in the gaps between those routes (the
1752
+ // entity decoding just below is one, since it reverses far less than a
1753
+ // browser does). Those gaps predate #1253 and it neither causes nor
1754
+ // closes any of them; no attempt is made here to enumerate them,
1755
+ // because every attempt so far has been incomplete.
1756
+ //
1757
+ // Scoped to the DEFAULT converter, which is all this branch is. The
1758
+ // client reader tries `converter.fromAttribute` FIRST and only falls
1759
+ // through to here when there is none, while this function has no
1760
+ // converter arm at all, so a prop declaring one is read differently by
1761
+ // the two sides regardless of what this line does. That gap predates
1762
+ // #1253 and is left alone rather than widened.
1763
+ try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = null; }
1733
1764
  } else instance[propName] = raw;
1734
1765
  }
1735
1766
  }
@@ -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;
@@ -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 {}