@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.
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.50",
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",
@@ -0,0 +1,178 @@
1
+ /**
2
+ * THE attribute reader, singular.
3
+ *
4
+ * `attributeChangedCallback` in `component.js` and `applyAttrsToInstance` in
5
+ * `render-server.js` are both thin callers of the two functions below, so the
6
+ * client and the SSR pass cannot drift on precedence, on a fallback, or on
7
+ * which attributes they read at all. They had drifted four ways before this
8
+ * module existed: the unparseable-JSON fallback (#1253), a missing converter
9
+ * arm (#1340), and three name-resolution gaps plus the entity decoding (#1341).
10
+ *
11
+ * The split between the two exports is the one place the sides legitimately
12
+ * differ. `resolveAttributeProperty` takes the name the PLATFORM would deliver,
13
+ * so the SSR caller lowercases its source name first and the client passes the
14
+ * browser's name straight through. Everything after that is common.
15
+ *
16
+ * WHY ITS OWN MODULE, rather than living beside the declaration semantics it
17
+ * interprets in `component.js`. That was the first shape, and `component.d.ts`
18
+ * cannot carry the declaration for it: `index.d.ts` re-exports that overlay
19
+ * with a bare `export *`, so a value declared there joins the ROOT public type
20
+ * surface of `@webjsdev/core` while `index.js` exports no such runtime value.
21
+ * An app writing `import { readAttributeValue } from '@webjsdev/core'` would
22
+ * then type-check and crash at load, which is exactly the phantom the #1031
23
+ * guard in `test/types/dts-no-phantom-exports.test.mjs` exists to catch, and it
24
+ * caught this one. Re-exporting the function from `index.js` would silence the
25
+ * guard by making an internal framework seam app-facing API, which is the wrong
26
+ * trade. A module with no `.d.ts` overlay keeps the seam internal and typed
27
+ * from its own JSDoc, which is how `escape.js` and `binding-prefixes.js`
28
+ * already work.
29
+ *
30
+ * It also drops an edge rather than adding one: `render-server.js` reaches the
31
+ * reader without importing `component.js` at all.
32
+ *
33
+ * lit solves the same problem structurally, though in the other direction.
34
+ * `@lit-labs/ssr`'s LitElementRenderer forwards its `attributeChangedCallback`
35
+ * into the element's own `_$attributeToProperty` through
36
+ * `lit-element/private-ssr-support.js`, so its server pass runs the browser's
37
+ * reader rather than a copy of it. lit can reach INTO the element because its
38
+ * elements are TypeScript with a generated declaration; WebJs ships buildless
39
+ * JS under a hand-written overlay, so the shared code moves out instead.
40
+ */
41
+
42
+ /**
43
+ * Read one attribute string into its declared property value.
44
+ *
45
+ * The caller owns only the one platform-specific step, lowercasing the source
46
+ * name at SSR; `resolveAttributeProperty` below owns NAME resolution and
47
+ * declaration normalisation for both sides, and this function owns the value.
48
+ * The two callers used to reach an attribute by different routes and NOT see
49
+ * the same attribute set, which #1341 closed.
50
+ *
51
+ * @param {import('./component.js').PropertyDeclaration} def normalised declaration (`{ type, … }`)
52
+ * @param {string|null} value the attribute text
53
+ *
54
+ * BOTH callers hand in an already-decoded value, so no branch decodes anything
55
+ * (#1341). The client's came out of the DOM, which decodes every character
56
+ * reference before any reader sees it; the SSR caller runs `decodeAttrEntities`
57
+ * once at its own call site, ahead of this function, over EVERY branch rather
58
+ * than only the ones that parse. This used to be a third `decode` parameter
59
+ * applied to the JSON and converter branches alone, which left a `String`-typed
60
+ * prop holding the raw source text at SSR while `getAttribute()` on the client
61
+ * returned the decoded one.
62
+ * @returns {unknown}
63
+ */
64
+ export function readAttributeValue(def, value) {
65
+ if (def.converter && def.converter.fromAttribute) {
66
+ // Deliberately UNGUARDED, and deliberately first (#1340). An author who
67
+ // supplies a converter owns the whole read, which is the same rule
68
+ // `_reflectAttribute` already states for `toAttribute`: its guards sit
69
+ // AFTER the converter branch because a custom converter is
70
+ // author-controlled. So a converter that throws throws, on both sides. At
71
+ // SSR that lands in per-component error isolation (render-server.js), which
72
+ // surfaces an error box in dev and an empty element at a 200 in prod with
73
+ // the cause in the server log; on the client it escapes
74
+ // attributeChangedCallback during upgrade. Catching on one side only would
75
+ // manufacture a fresh divergence (an SSR paint holding a fallback against a
76
+ // browser holding the constructor value), which is worse than both sides
77
+ // failing. lit propagates too (reactive-element `_$attributeToProperty`).
78
+ //
79
+ // A converter RETURN that SSR cannot serialise needs no guard here either.
80
+ // Unreflected it is only a property, and a function in a text hole renders
81
+ // as nothing. Reflected, the #1169 function guard and the #1253
82
+ // unserializable guard in `_reflectAttribute` already run on the SSR side
83
+ // and cover whatever the converter produced.
84
+ //
85
+ // The converter is handed DECODED text, which matters more here than
86
+ // anywhere: the documented reason to write one is a type the built-in
87
+ // converters cannot parse (Date, Map, Set), those parse their input, and
88
+ // `escapeAttr` encodes every `"` in an emitted attribute, so an encoded
89
+ // `{"a":1}` would throw inside the author's converter and (since
90
+ // the throw is deliberately uncaught) render an empty component at a 200.
91
+ // Both callers now decode before calling, so it gets the same text on both
92
+ // sides for hand-written markup as well as for anything WebJs emitted
93
+ // (#1341); this used to be a `decode` argument applied on this branch and
94
+ // the JSON one.
95
+ return def.converter.fromAttribute(value, def.type);
96
+ }
97
+ if (def.type === Number) return value == null ? null : Number(value);
98
+ if (def.type === Boolean) return value != null && value !== 'false';
99
+ if (def.type === Object || def.type === Array) {
100
+ // An attribute that is not parseable JSON yields `null` rather than the raw
101
+ // string (#1253), because a STRING is never a valid value for a property
102
+ // the author declared `Object` or `Array`, whatever put it there. lit's
103
+ // `defaultConverter.fromAttribute` lands on the same `null`, for the reason
104
+ // its own comment gives: an element does not complain about being
105
+ // mis-configured. Both readers reach this line, so they cannot disagree.
106
+ //
107
+ // An attribute that was never PRESENT does not reach either reader, so such
108
+ // a prop simply keeps its constructor value.
109
+ //
110
+ // The two readers now see the SAME attribute set, so this fallback is not
111
+ // the only thing they agree on (#1341). Both resolve a name through
112
+ // `resolveAttributeProperty` below, which matches the same
113
+ // `d.attribute || hyphenate(k)` expression `observedAttributes` maps over
114
+ // and nothing else, so it skips a `state: true` prop and ignores a name that
115
+ // maps to no attribute-backed property exactly as the observed list does.
116
+ // The SSR caller lowercases its source name first, because the browser
117
+ // lowercases while parsing. And both are handed a value with every
118
+ // character reference already decoded, legacy semicolon-less names included.
119
+ if (value == null) return null;
120
+ try { return JSON.parse(value); } catch { return null; }
121
+ }
122
+ return value;
123
+ }
124
+
125
+ /** Kebab-case a property name for its default HTML attribute. @param {string} s */
126
+ function hyphenate(s) {
127
+ return s.replace(/([A-Z])/g, '-$1').toLowerCase();
128
+ }
129
+
130
+ /**
131
+ * Resolve an attribute NAME to the property it feeds, and to that property's
132
+ * normalised declaration.
133
+ *
134
+ * `state: true` props are skipped, because they are absent from
135
+ * `observedAttributes` and so the browser never delivers their attribute to any
136
+ * reader. lit reaches the same outcome the same way, resolving through the very
137
+ * map `observedAttributes` is built from (`reactive-element.ts`
138
+ * `_$attributeToProperty`).
139
+ *
140
+ * `attrName` must already be the name the PLATFORM would deliver. The browser
141
+ * lowercases attribute names while parsing, so the SSR caller lowercases its
142
+ * source name before calling and the client caller passes the browser's name
143
+ * through untouched. That one step is the only thing that differs between the
144
+ * two sides, which is why it lives in the callers and not here. A custom
145
+ * `attribute` option is matched VERBATIM, as lit matches it and as a browser
146
+ * would, so a camelCase custom attribute stays unreachable from markup rather
147
+ * than being reachable at SSR only. Lowercasing the DECLARED side would make
148
+ * SSR read MORE than a browser can deliver, which is the bug this function
149
+ * exists to remove, so do not add it.
150
+ *
151
+ * The match is EXACTLY `d.attribute || hyphenate(k)`, the same expression
152
+ * `observedAttributes` maps over, and nothing else. A `props[attrName] ||
153
+ * props[camelCase(attrName)]` fallback used to sit after the loop, so a prop
154
+ * declaring a custom attribute also answered to its PROPERTY name. Both readers
155
+ * carried that code, which read like an agreement and was not one: the browser
156
+ * only ever calls in with a name from `observedAttributes`, and that list holds
157
+ * the declared attribute alone, so the fallback was unreachable on the client
158
+ * and live on the server. Measured, `open: prop(Boolean, { attribute:
159
+ * 'is-open' })` with `<my-el open>` SSR'd `true` and upgraded to the
160
+ * constructor value. Deriving the match from the same expression the observed
161
+ * list is built from is what makes the two sides agree by construction rather
162
+ * than by two copies staying in step (#1341).
163
+ *
164
+ * @param {any} Cls the component class
165
+ * @param {string} attrName
166
+ * @returns {{ propName: string, def: PropertyDeclaration } | undefined}
167
+ * `undefined` when the attribute maps to no attribute-backed property, in
168
+ * which case the caller must IGNORE the attribute entirely (#1341).
169
+ */
170
+ export function resolveAttributeProperty(Cls, attrName) {
171
+ const props = (Cls && Cls.properties) || {};
172
+ for (const [k, decl] of Object.entries(props)) {
173
+ const d = typeof decl === 'object' && decl !== null ? decl : { type: decl };
174
+ if (d.state) continue;
175
+ if ((d.attribute || hyphenate(k)) === attrName) return { propName: k, def: d };
176
+ }
177
+ return undefined;
178
+ }
@@ -213,3 +213,13 @@ export declare function prop<T>(
213
213
  export declare function prop<T = string>(
214
214
  opts?: PropertyDeclaration<T>
215
215
  ): PropertyDeclaration<T>;
216
+
217
+ // The shared attribute reader (`resolveAttributeProperty`, #1341) is NOT here,
218
+ // and nothing about it belongs here. It lives in `src/attribute-reader.js`,
219
+ // which has no `exports` entry, because two guards bracket every mapped entry
220
+ // and `./component` is one: #388 forces every runtime named export of
221
+ // `component.js` to be declared in this file, and #1031 forces every
222
+ // declaration reachable from `index.d.ts` (which re-exports this file with
223
+ // `export *`) to exist on `index.js` at runtime. Together they leave no way to
224
+ // add an INTERNAL named export to `component.js`. That module's header carries
225
+ // the full reasoning.
package/src/component.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { render as clientRender } from './render-client.js';
2
+ import { readAttributeValue } from './attribute-reader.js';
2
3
  import { setActiveActionSignal } from './action-abort-client.js';
3
4
  import { carriesFunction } from './form-action.js';
4
5
  import { isCSS, adoptStyles } from './css.js';
5
6
  import { register, tagOf } from './registry.js';
6
7
  import { parse as deserializeProp } from './serialize.js';
7
8
  import { Signal } from './signal.js';
9
+ import { resolveAttributeProperty } from './attribute-reader.js';
8
10
  import {
9
11
  captureAuthoredChildren,
10
12
  adoptSSRAssignments,
@@ -85,7 +87,11 @@ const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undef
85
87
  *
86
88
  * @property {{ fromAttribute: (value: string|null, type?: Function) => unknown, toAttribute: (value: unknown, type?: Function) => string|null }} [converter]
87
89
  * Custom serialization/deserialization pair for the HTML attribute.
88
- * `fromAttribute` is called in `attributeChangedCallback`;
90
+ * `fromAttribute` is called by BOTH attribute readers through the shared
91
+ * `readAttributeValue` in `attribute-reader.js`: the client
92
+ * `attributeChangedCallback` and the SSR
93
+ * `applyAttrsToInstance` in `render-server.js` (#1340). So it runs
94
+ * server-side too, and must not touch browser globals.
89
95
  * `toAttribute` is called when reflecting back to the attribute.
90
96
  * If omitted, the built-in type-based coercion is used.
91
97
  */
@@ -100,6 +106,7 @@ function defaultHasChanged(a, b) {
100
106
  return a !== b;
101
107
  }
102
108
 
109
+
103
110
  /**
104
111
  * `String(v)` that cannot itself throw.
105
112
  *
@@ -157,6 +164,42 @@ function warnFunctionReflection(host, propName, attrName) {
157
164
  );
158
165
  }
159
166
 
167
+ /**
168
+ * Warn that a value with no JSON representation was dropped rather than
169
+ * reflected (#1253).
170
+ *
171
+ * `JSON.stringify` throws for three reasons an app hits in practice: a cycle,
172
+ * a `BigInt`, and an author `toJSON()` that throws. All three mean the same
173
+ * thing here, that there is no string to put in the attribute, so all three
174
+ * get the same answer as a function does.
175
+ *
176
+ * The caught message IS included, unlike `warnFunctionReflection`, which
177
+ * withholds its value on purpose. A function's string form is its source,
178
+ * which is the leak that guard exists to prevent; `JSON.stringify`'s own
179
+ * message names the property path rather than a value, and the sibling
180
+ * `.prop=${val}` SSR drop in `render-server.js` already ends the same way.
181
+ *
182
+ * UNCONDITIONAL, for the reason recorded on `warnFunctionReflection` above: a
183
+ * `NODE_ENV` gate is folded to a constant by the dist build, which would make
184
+ * the SSR half unreachable in every published build, and the SSR half is where
185
+ * the component silently vanishes.
186
+ *
187
+ * @param {{ constructor: unknown, tagName?: string }} host
188
+ * @param {string} propName
189
+ * @param {string} attrName
190
+ * @param {string} [detail] the message `JSON.stringify` threw
191
+ */
192
+ function warnUnserializableReflection(host, propName, attrName, detail) {
193
+ if (typeof console === 'undefined' || !console.warn) return;
194
+ const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown';
195
+ console.warn(
196
+ `[webjs] reflect:true property "${propName}" on <${tag}> holds a value `
197
+ + `JSON.stringify cannot serialize (a cycle, a BigInt, or a throwing `
198
+ + `toJSON), so it has no HTML attribute representation. Removing `
199
+ + `"${attrName}" instead. Detail: ${detail}`
200
+ );
201
+ }
202
+
160
203
  /**
161
204
  * A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
162
205
  * while staying JSDoc-only and no-build.
@@ -745,12 +788,40 @@ class WebComponentBase extends Base {
745
788
  } else if (value == null) {
746
789
  this.removeAttribute(attrName);
747
790
  } else if (decl.type === Object || decl.type === Array) {
791
+ // One rule about an unserializable reflected value, in two halves.
792
+ //
748
793
  // A JSON-typed prop CARRYING a function is safe and stays whole:
749
794
  // `JSON.stringify` drops a function to `null` in an array and omits
750
795
  // the key in an object, so `[1, 2, fn]` serializes to `[1,2,null]`
751
796
  // with no source and no data loss. Refusing here would discard the
752
797
  // `1` and the `2` on a path that never leaked.
753
- this.setAttribute(attrName, JSON.stringify(value));
798
+ //
799
+ // A value `JSON.stringify` cannot serialize AT ALL is the other half
800
+ // (#1253): a cycle, a `BigInt`, or an author `toJSON()` that throws.
801
+ // The throw used to escape reflection entirely, so a property
802
+ // assignment threw from the setter, a client upgrade threw out of
803
+ // `connectedCallback` before the first render, and an SSR render was
804
+ // swallowed by per-component isolation, which surfaces an error box in
805
+ // dev and emits the component EMPTY at a 200 in PRODUCTION, where the
806
+ // cause is only in the server log. There is no string to put in the
807
+ // attribute, so the
808
+ // attribute goes, exactly as it does for a function. The `catch` IS
809
+ // the detection: `JSON.stringify` already walks the value, so its own
810
+ // failure covers all three causes, where a cycle-only pre-walk would
811
+ // miss the other two and pay a full traversal per reflection.
812
+ let serialized;
813
+ let serializable = true;
814
+ try {
815
+ serialized = JSON.stringify(value);
816
+ } catch (e) {
817
+ serializable = false;
818
+ this.removeAttribute(attrName);
819
+ warnUnserializableReflection(this, propName, attrName, e && e.message);
820
+ }
821
+ // Outside the `try` on purpose, so a genuine `setAttribute` failure
822
+ // (an invalid attribute name) still surfaces instead of being folded
823
+ // into the unserializable path.
824
+ if (serializable) this.setAttribute(attrName, serialized);
754
825
  } else if (carriesFunction(value)) {
755
826
  // The string fall-through is the one place a CARRIED function still
756
827
  // leaks. `String([fn])` is `Array.prototype.join`, which runs
@@ -1077,36 +1148,22 @@ class WebComponentBase extends Base {
1077
1148
  // resulting attributeChangedCallback to avoid infinite loops.
1078
1149
  if (this.__reflectingAttribute) return;
1079
1150
 
1080
- const Ctor = /** @type any */ (this.constructor);
1081
- const allProps = (Ctor.properties || {});
1082
- // Resolve the incoming attribute name to its property. A custom
1083
- // `attribute` option wins; otherwise the kebab-cased property name. Falls
1084
- // back to the camelCase of the attribute for the common (kebab) case.
1085
- let propName, raw;
1086
- for (const [k, decl] of Object.entries(allProps)) {
1087
- const d = typeof decl === 'object' ? decl : { type: decl };
1088
- if ((d.attribute || hyphenate(k)) === name) { propName = k; raw = decl; break; }
1089
- }
1090
- if (raw === undefined) { propName = camelCase(name); raw = allProps[propName] || allProps[name]; }
1091
- if (raw === undefined) return;
1092
- // A declaration is either a full descriptor (`{ type: Number, … }`) or the
1093
- // bare-constructor shorthand the factory accepts (`count: Number`), in which
1094
- // case the value IS the type. Normalise so type-based coercion fires either
1095
- // way (matches `_initializeProperties`).
1096
- const def = typeof raw === 'object' ? raw : { type: raw };
1097
-
1098
- let v;
1099
- if (def.converter && def.converter.fromAttribute) {
1100
- v = def.converter.fromAttribute(value, def.type);
1101
- } else if (def.type === Number) {
1102
- v = value == null ? null : Number(value);
1103
- } else if (def.type === Boolean) {
1104
- v = value != null && value !== 'false';
1105
- } else if (def.type === Object || def.type === Array) {
1106
- try { v = value == null ? null : JSON.parse(value); } catch { v = value; }
1107
- } else {
1108
- v = value;
1109
- }
1151
+ // One name resolver for both sides (#1341). The browser only calls this for
1152
+ // a name in `observedAttributes`, and it has already lowercased that name
1153
+ // while parsing, so it passes straight through; the SSR caller lowercases
1154
+ // its source name first. `resolveAttributeProperty` also normalises the
1155
+ // declaration, so a bare-constructor shorthand (`count: Number`) and the
1156
+ // long form (`{ type: Number, … }`) both arrive here as `{ type, … }`.
1157
+ const resolved = resolveAttributeProperty(this.constructor, name);
1158
+ if (resolved === undefined) return;
1159
+ const { propName, def } = resolved;
1160
+
1161
+ // One reader for both sides, in `attribute-reader.js`: this and
1162
+ // `applyAttrsToInstance` in `render-server.js` call the same function, so
1163
+ // precedence and every fallback are shared rather than mirrored (#1340).
1164
+ // It takes an already-decoded value, which the DOM has done here and
1165
+ // `decodeAttrEntities` does at the SSR call site (#1341).
1166
+ const v = readAttributeValue(def, value);
1110
1167
 
1111
1168
  if (this[propName] !== v) {
1112
1169
  this[propName] = v;