@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.
package/index-browser.js CHANGED
@@ -30,6 +30,7 @@ export { render } from './src/render-client.js';
30
30
  export { escapeText, escapeAttr } from './src/escape.js';
31
31
  export { notFound, redirect, forbidden, unauthorized, isNotFound, isRedirect, isForbidden, isUnauthorized } from './src/nav.js';
32
32
  export { cspNonce } from './src/csp-nonce.js';
33
+ export { asset } from './src/asset-url.js';
33
34
  export { repeat, isRepeat } from './src/repeat.js';
34
35
  export { Suspense, isSuspense } from './src/suspense.js';
35
36
  export { connectWS } from './src/websocket-client.js';
package/index.d.ts CHANGED
@@ -104,6 +104,7 @@ export { Task, TaskStatus } from './src/task.js';
104
104
  // src/*.d.ts (#388 fixed the index.d.ts drift from index.js).
105
105
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
106
106
  export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
107
+ export { asset, setAssetUrlProvider } from './src/asset-url.js';
107
108
  export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
108
109
  export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
109
110
  // Client action-abort plumbing (#492): a superseded async render aborts its
package/index.js CHANGED
@@ -15,6 +15,7 @@ export { render } from './src/render-client.js';
15
15
  export { escapeText, escapeAttr } from './src/escape.js';
16
16
  export { notFound, redirect, forbidden, unauthorized, isNotFound, isRedirect, isForbidden, isUnauthorized } from './src/nav.js';
17
17
  export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
18
+ export { asset, setAssetUrlProvider } from './src/asset-url.js';
18
19
  export { repeat, isRepeat } from './src/repeat.js';
19
20
  export { Suspense, isSuspense } from './src/suspense.js';
20
21
  export { connectWS } from './src/websocket-client.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.44",
3
+ "version": "0.7.46",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -0,0 +1,3 @@
1
+ /** Content-hashed `public/` asset urls (#1194). Server-side resolver; the browser bundle drops setAssetUrlProvider and `asset` returns its argument. */
2
+ export function setAssetUrlProvider(fn: (path: string) => string): void;
3
+ export function asset(path: string): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Resolve a `public/` asset url to its content-fingerprinted form, so a
3
+ * deploy that changes the file changes the url and no cache can serve the
4
+ * previous bytes.
5
+ *
6
+ * import { html, asset } from '@webjsdev/core';
7
+ * html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
8
+ *
9
+ * On the server, `@webjsdev/server` installs a provider that appends
10
+ * `?v=<content-hash>`; the framework then serves a `?v=`-carrying request
11
+ * `immutable` for a year instead of the short fallback. On the browser there
12
+ * is no provider and the path is returned UNCHANGED.
13
+ *
14
+ * USE IT IN A PAGE, LAYOUT, OR METADATA ROUTE, not inside a component that
15
+ * ships to the browser. Those modules render only on the server, so the
16
+ * fingerprinted url is the one the browser sees and keeps. A component is
17
+ * different: hydration is a full client RE-RENDER (`render-client.js` drops
18
+ * the hydrate marker and `createInstance` replaces the SSR'd children), so
19
+ * the client value overwrites the server one. With no provider in the
20
+ * browser, `asset()` returns the bare path there, so an `<img src=${asset(
21
+ * '/public/logo.svg')}>` inside a shipping component paints the hashed url
22
+ * at SSR and then swaps it for the un-hashed one on upgrade, fetching the
23
+ * same bytes twice and leaving the short-lived url in the DOM. It still
24
+ * WORKS (the path is always valid), which is why this is a convention rather
25
+ * than a `webjs check` rule, but it silently forfeits the caching the call
26
+ * was for. Import the asset url into a layout, or accept the plain path.
27
+ *
28
+ * The sibling `cspNonce()` carries the same server/browser asymmetry and the
29
+ * same scoping, for the same hydration reason.
30
+ *
31
+ * Call it INSIDE the render function, not at module scope. A depth-0 call is a
32
+ * module-scope side effect, which the elision analyser reads as client work, so
33
+ * hoisting `const CSS = asset('/public/app.css')` pins the whole page or layout
34
+ * into the browser bundle for no benefit. Resolver ordering is not the issue
35
+ * (the server installs its resolver at boot, before any app module loads).
36
+ *
37
+ * Under `webjs.basePath`, write the prefix yourself: `asset('/app/public/x.css')`.
38
+ * The framework only base-path-prefixes urls IT emits, so an author-written
39
+ * url is already your responsibility (an un-prefixed one 404s under a
40
+ * sub-path deploy with or without this helper). `asset()` strips the prefix
41
+ * to find the file and keeps it on the url it returns.
42
+ *
43
+ * Mark files that change only with a DEPLOY. The hash is memoized for the
44
+ * process lifetime, and prod never rebuilds, so a `public/` file rewritten in
45
+ * place while the server runs keeps its old url while being served `immutable`
46
+ * for a year. A build artifact (a compiled stylesheet, a bundled script, a
47
+ * committed image) is exactly right; a runtime-written upload is not, and
48
+ * should keep its plain path (or carry its own version in the filename).
49
+ *
50
+ * Why this is opt-in rather than automatic. An earlier attempt (#1196)
51
+ * rewrote asset urls by matching the assembled HTML, and two deep-review
52
+ * rounds found six major defects, five of them the same bug: at that layer
53
+ * framework output and author data are indistinguishable, so the matcher kept
54
+ * touching things it did not own (a custom element's reactive prop, a
55
+ * rendered code sample, a `rel=preload` hint whose real request comes from
56
+ * CSS, a data-driven `src` pointing at `/.env`). Marking the url at the point
57
+ * the author writes it makes the meaning unambiguous, so the blast radius is
58
+ * exactly the set of urls someone deliberately marked and nothing else in the
59
+ * document is ever read or rewritten.
60
+ *
61
+ * Scope note: only a `public/` path is fingerprinted, matching what the
62
+ * static-asset route will actually serve. Anything else is returned
63
+ * unchanged rather than guessed at.
64
+ */
65
+
66
+ /** @type {((path: string) => string) | null} */
67
+ let _provider = null;
68
+
69
+ /**
70
+ * Internal: server-only wiring. `@webjsdev/server` calls this once at load
71
+ * time to install the real resolver. Browser builds never call it, so
72
+ * `asset()` stays an identity function there.
73
+ *
74
+ * @param {(path: string) => string} fn
75
+ * @returns {void}
76
+ */
77
+ export function setAssetUrlProvider(fn) {
78
+ _provider = fn;
79
+ }
80
+
81
+ /**
82
+ * The runtime function. Returns the fingerprinted url on the server, or the
83
+ * path unchanged when there is no provider (browser), when fingerprinting is
84
+ * off (dev, so dev output stays byte-identical), or when the file cannot be
85
+ * resolved. Every failure mode degrades to the plain path, never to a broken
86
+ * one.
87
+ *
88
+ * @param {string} path a root-absolute same-origin path, e.g. `/public/app.css`
89
+ * @returns {string}
90
+ */
91
+ export function asset(path) {
92
+ if (typeof path !== 'string' || !path) return path;
93
+ if (!_provider) return path;
94
+ try {
95
+ return _provider(path) || path;
96
+ } catch {
97
+ return path;
98
+ }
99
+ }
package/src/component.js CHANGED
@@ -99,6 +99,25 @@ function defaultHasChanged(a, b) {
99
99
  return a !== b;
100
100
  }
101
101
 
102
+ /**
103
+ * `String(v)` that cannot itself throw.
104
+ *
105
+ * A rejection reason is whatever the author rejected with, and not every value
106
+ * converts to a primitive: `Object.create(null)` has no `toString`, and a
107
+ * revoked Proxy throws on any operation. Coercing one in an error-reporting
108
+ * path turned a reportable failure into a second, unreported one.
109
+ *
110
+ * @param {unknown} v
111
+ * @returns {string}
112
+ */
113
+ function safeString(v) {
114
+ try {
115
+ return String(v);
116
+ } catch {
117
+ return Object.prototype.toString.call(v);
118
+ }
119
+ }
120
+
102
121
  /**
103
122
  * A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
104
123
  * while staying JSDoc-only and no-build.
@@ -1176,7 +1195,7 @@ class WebComponentBase extends Base {
1176
1195
  // (shouldUpdate=false) running during the fetch does NOT resolve
1177
1196
  // updateComplete early: the pending commit owns the resolution.
1178
1197
  this.__pendingAsyncCommits = (this.__pendingAsyncCommits || 0) + 1;
1179
- pendingCommit.then(() => {
1198
+ const settle = () => {
1180
1199
  // Always decrement first, even when superseded, so the in-flight
1181
1200
  // count never leaks (a superseded cycle returns below without
1182
1201
  // committing, but it is no longer pending).
@@ -1189,6 +1208,37 @@ class WebComponentBase extends Base {
1189
1208
  }
1190
1209
  // --- 7-8 + updateComplete ---
1191
1210
  this._postCommit(changedProperties);
1211
+ };
1212
+ // A rejection has to settle the cycle too, or the counter stays >= 1
1213
+ // forever: that both wedges `updateComplete` for this instance and
1214
+ // disables the `!this.__pendingAsyncCommits` escape below for every
1215
+ // later non-committing cycle. `_commitAsync` is written not to reject,
1216
+ // but it is not the only thenable that reaches here: `update()` is a
1217
+ // documented override point and may return one, so the handler belongs
1218
+ // at this site rather than only at the one implementation of it.
1219
+ pendingCommit.then(settle, (error) => {
1220
+ // The token check gates the DOM write, NOT the release. A superseded
1221
+ // cycle rejecting late (a discarded fetch, or the #492 abort of its own
1222
+ // in-flight action surfacing as an AbortError) must not commit its
1223
+ // error state over the newer render that already replaced it, which is
1224
+ // the same rule both handlers inside _commitAsync follow. `settle()`
1225
+ // still runs either way: it does its own token check before touching
1226
+ // anything, and the decrement it owns has to happen regardless or the
1227
+ // counter wedges exactly as it did before this handler existed.
1228
+ // `finally`, not a trailing statement: the release must survive
1229
+ // anything the reporting path throws. `String(error)` is evaluated in
1230
+ // the argument expression, OUTSIDE _handleRenderError's own try/catch,
1231
+ // so a rejection reason that cannot be coerced (a null-prototype
1232
+ // object, a revoked Proxy) threw a TypeError straight out of this
1233
+ // handler and skipped the decrement, wedging the counter in exactly
1234
+ // the way this handler exists to prevent.
1235
+ try {
1236
+ if (token === this.__renderToken) {
1237
+ this._handleRenderError(error instanceof Error ? error : new Error(safeString(error)));
1238
+ }
1239
+ } finally {
1240
+ settle();
1241
+ }
1192
1242
  });
1193
1243
  return;
1194
1244
  }
@@ -1399,7 +1449,23 @@ class WebComponentBase extends Base {
1399
1449
  return Promise.resolve(pending).then(
1400
1450
  (tpl) => {
1401
1451
  if (token !== this.__renderToken) return; // superseded by a newer render
1402
- clientRender(tpl, this._renderRoot);
1452
+ // The COMMIT throws too, not just the fetch, and a sibling rejection
1453
+ // handler cannot see it: `.then(onFulfil, onRejected)` never routes
1454
+ // onFulfil's own throw to onRejected. Left uncaught, the returned
1455
+ // promise rejected and _performRender's `.then` had no rejection
1456
+ // handler, so the error escaped as an unhandled rejection,
1457
+ // renderError() never ran, __pendingAsyncCommits never decremented
1458
+ // (wedging it >= 1 forever, which also disables the non-committing
1459
+ // cycle's _resolveUpdate escape), and updateComplete never settled.
1460
+ // The sync path routes an identical throw to the same boundary, so
1461
+ // this is what makes the two paths agree.
1462
+ try {
1463
+ clientRender(tpl, this._renderRoot);
1464
+ } catch (commitError) {
1465
+ this._handleRenderError(
1466
+ commitError instanceof Error ? commitError : new Error(String(commitError)),
1467
+ );
1468
+ }
1403
1469
  },
1404
1470
  (error) => {
1405
1471
  if (token !== this.__renderToken) return;
@@ -0,0 +1,163 @@
1
+ import { isBindingPrefix } from './binding-prefixes.js';
2
+
3
+ /**
4
+ * Form-action attribute guard (#1154).
5
+ *
6
+ * Both SSR state machines and the client renderer import from here so the
7
+ * rule cannot drift between them. Each renderer commits attribute, boolean and
8
+ * property holes on separate branches, so the rule has more call sites than it
9
+ * looks like from any one file, and they are easy to change one at a time.
10
+ * That is why the predicate lives here rather than at each site.
11
+ *
12
+ * Two entry points, and the difference between them is the point:
13
+ * `assertNotFunctionActionAttr` is name-based, because an ATTRIBUTE is
14
+ * stringified into the markup on whatever tag it appears.
15
+ * `assertNotFunctionReflectedActionProp` is name-and-tag-based, because a
16
+ * PROPERTY only reaches the markup where the DOM reflects it. Using the first
17
+ * on the property path refused `<div .action=${fn}>`, which never leaked.
18
+ *
19
+ * The second SSR machine (`streamTemplate`) is reached only through
20
+ * `renderToStream(v, { ssr: false })`, which no page render uses: the server
21
+ * renders every page, Suspense included, via `renderToString`. Guarding it is
22
+ * about the public API surface rather than a live page leak, so do not infer
23
+ * from a bug there that pages were affected.
24
+ */
25
+
26
+ /**
27
+ * Refuse to stringify a function interpolated into a form-action attribute.
28
+ *
29
+ * At SSR a `'use server'` import is the REAL server function (the RPC stub
30
+ * only exists in the browser), and a plain attribute hole commits via
31
+ * `String(val)`, so without this guard the action's SOURCE, secrets included,
32
+ * is serialized into the served HTML. The client renderer guards the same
33
+ * commit so a client re-render cannot write the source into the live DOM
34
+ * through a template binding. It does NOT cover property REFLECTION: a prop
35
+ * declared `reflect: true` writes `String(value)` to its attribute from the
36
+ * setter, which is not a commit site and is not reachable from here.
37
+ *
38
+ * Name-based on purpose (`action`, plus `formaction`, the submit-button
39
+ * override, which leaks identically): a function stringified under these
40
+ * names is never useful on any tag, and other attributes keep today's
41
+ * stringify behaviour so the claim stays narrow.
42
+ *
43
+ * @param {unknown} val the hole's resolved value
44
+ * @param {string} attrName the attribute being committed (any case)
45
+ * @param {string} [tag] lowercased owner tag, for the error message
46
+ */
47
+ export function assertNotFunctionActionAttr(val, attrName, tag) {
48
+ if (!carriesFunction(val)) return;
49
+ if (!isFormActionAttr(attrName)) return;
50
+ throw new Error(formActionError(attrName, tag));
51
+ }
52
+
53
+ /**
54
+ * Refuse a function in a `.prop` binding, but ONLY where that property is a
55
+ * reflected IDL attribute and therefore really does write the source out.
56
+ *
57
+ * The attribute form leaks on any tag, because an attribute is stringified
58
+ * into the HTML wherever it appears. A PROPERTY is different: it leaks only
59
+ * when the DOM reflects it back to an attribute, and that is per element.
60
+ * `action` reflects on `<form>`; `formAction` reflects on `<button>` and
61
+ * `<input>`. Anywhere else (`<div .action=${fn}>`, `<li .action=${fn}>`, and
62
+ * `.action` on a `<button>`) the property is a plain expando: nothing is
63
+ * stringified and nothing reaches the markup.
64
+ *
65
+ * Gating on "is not a custom element" instead refused all of those, which is a
66
+ * false positive on a binding WebJs supports and that never leaked. A guard
67
+ * that turns a working delegated-command pattern (`<button .action=${() =>
68
+ * save(row)}>`) into a render error is not a narrower claim, it is a wrong one.
69
+ *
70
+ * A custom element is excluded for the same reason rather than a different
71
+ * one: it has no IDL reflection, so its `.action` is an author-defined
72
+ * property and a function is a legitimate value. Note the separate path that
73
+ * DOES write the source there, a prop declared `reflect: true`, which runs in
74
+ * the setter and never reaches any commit site.
75
+ *
76
+ * @param {unknown} val the hole's resolved value
77
+ * @param {string} propName the property being assigned (any case)
78
+ * @param {string} [tag] lowercased owner tag
79
+ */
80
+ export function assertNotFunctionReflectedActionProp(val, propName, tag) {
81
+ if (!reflectsAsFormAction(propName, tag)) return;
82
+ assertNotFunctionActionAttr(val, propName, tag);
83
+ }
84
+
85
+ /**
86
+ * Is `propName` an IDL attribute that `tag` reflects to a content attribute?
87
+ * @param {string} propName
88
+ * @param {string} [tag]
89
+ * @returns {boolean}
90
+ */
91
+ function reflectsAsFormAction(propName, tag) {
92
+ let name = String(propName).toLowerCase();
93
+ if (isBindingPrefix(name[0])) name = name.slice(1);
94
+ const owner = String(tag || '').toLowerCase();
95
+ if (name === 'action') return owner === 'form';
96
+ if (name === 'formaction') return owner === 'button' || owner === 'input';
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * Does stringifying this value expose a function's source?
102
+ *
103
+ * The commit sites do `String(val)`, and `Array.prototype.toString` stringifies
104
+ * each element through `String()` too, so `action=${[serverAction]}` leaks
105
+ * exactly as `action=${serverAction}` does. Recursive because nested arrays
106
+ * join the same way.
107
+ *
108
+ * Tracks visited arrays because `Array.prototype.join` has a cycle guard and
109
+ * this has to match it. A self-referential array stringifies to `''` rather
110
+ * than recursing forever, so a naive walk would turn a render that used to
111
+ * succeed into a stack overflow. Refusing to leak must not become a new way
112
+ * to crash.
113
+ *
114
+ * Deliberately NOT a check on the stringified result: sniffing the output for
115
+ * something function-shaped would misfire on a legitimate URL, and the value's
116
+ * shape is the thing actually being claimed. An object with a hand-written
117
+ * `toString` that returns a function's source is out of scope; that is
118
+ * deliberate exfiltration, not the accident this guards.
119
+ *
120
+ * @param {unknown} val
121
+ * @param {Set<unknown>} [seen]
122
+ * @returns {boolean}
123
+ */
124
+ function carriesFunction(val, seen) {
125
+ if (typeof val === 'function') return true;
126
+ if (!Array.isArray(val)) return false;
127
+ const visited = seen || new Set();
128
+ if (visited.has(val)) return false;
129
+ visited.add(val);
130
+ return val.some((v) => carriesFunction(v, visited));
131
+ }
132
+
133
+ /**
134
+ * Is this the name of a form-action attribute?
135
+ *
136
+ * Strips a leading binding sigil before comparing. The SSR state machines
137
+ * accumulate the AUTHORED name, and only the unquoted `after-eq` branch splits
138
+ * the prefix off, so a quoted hole arrives here as `.action` / `?action` /
139
+ * `@action` with the sigil still attached. Comparing the raw name let every one
140
+ * of those through, which is the same leak wearing a different hat: the
141
+ * renderer treats a quoted binding hole as a plain attribute and stringifies it.
142
+ *
143
+ * @param {string} attrName
144
+ * @returns {boolean}
145
+ */
146
+ function isFormActionAttr(attrName) {
147
+ let name = String(attrName).toLowerCase();
148
+ if (isBindingPrefix(name[0])) name = name.slice(1);
149
+ return name === 'action' || name === 'formaction';
150
+ }
151
+
152
+ /**
153
+ * The refusal message. Deliberately never echoes the function's source, which
154
+ * is the very thing being withheld.
155
+ * @param {string} attrName
156
+ * @param {string} [tag]
157
+ */
158
+ function formActionError(attrName, tag) {
159
+ return `[webjs] a function was interpolated into ${String(attrName).toLowerCase()}= `
160
+ + `on <${tag || 'form'}>. Stringifying a function into HTML would leak its `
161
+ + `source (a 'use server' action's body included) to every visitor, so this `
162
+ + `is refused. Pass a URL string instead.`;
163
+ }
@@ -1,6 +1,7 @@
1
1
  import { isTemplate, MARKER } from './html.js';
2
2
  import { BINDING_PREFIXES, isBindingPrefix } from './binding-prefixes.js';
3
3
  import { escapeAttr } from './escape.js';
4
+ import { assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp } from './form-action.js';
4
5
  import { isRepeat } from './repeat.js';
5
6
  import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
6
7
  import { Signal } from './signal.js';
@@ -671,17 +672,41 @@ function applyPart(part, value, _prev, allValues) {
671
672
  break;
672
673
  case 'attr': {
673
674
  if (value == null || value === false) part.el.removeAttribute(part.name);
674
- else part.el.setAttribute(part.name, String(value));
675
+ else {
676
+ // #1154: refuse to stringify a function into action=/formaction=
677
+ // (mirrors the SSR guard, so a client re-render cannot write a
678
+ // server action's source into the live DOM).
679
+ assertNotFunctionActionAttr(value, part.name, part.el.localName);
680
+ part.el.setAttribute(part.name, String(value));
681
+ }
675
682
  break;
676
683
  }
677
684
  case 'prop':
685
+ // `.action=${fn}` is a leak too, not just the attribute form, but only
686
+ // where the property is a REFLECTED IDL attribute: assigning a function
687
+ // to `form.action` (or `.formAction` on a button/input) stringifies it
688
+ // into that element's own content attribute in a real browser. On any
689
+ // other native tag it is a plain expando that reflects nothing, and on a
690
+ // custom element it is an author-defined property, so a function is
691
+ // legitimate in both. The helper owns that distinction.
692
+ //
693
+ // Not covered here, because it is not a commit: a custom element's prop
694
+ // declared `reflect: true` writes String(value) from its own setter.
695
+ assertNotFunctionReflectedActionProp(value, part.name, part.el.localName);
678
696
  /** @type any */ (part.el)[part.name] = value;
679
697
  break;
680
698
  case 'bool':
699
+ // #1154: never leaked (a boolean binding stringifies nothing), but
700
+ // `?action=${fn}` is meaningless in every case, and refusing it keeps
701
+ // this renderer agreeing with both SSR machines.
702
+ assertNotFunctionActionAttr(value, part.name, part.el.localName);
681
703
  if (value) part.el.setAttribute(part.name, '');
682
704
  else part.el.removeAttribute(part.name);
683
705
  break;
684
706
  case 'event':
707
+ // NOT guarded, deliberately. An event binding never stringifies its
708
+ // value, and a function is the legitimate thing to pass one, so
709
+ // `<my-el @action=${handler}>` has to keep working.
685
710
  part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
686
711
  break;
687
712
  case 'element':
@@ -692,7 +717,10 @@ function applyPart(part, value, _prev, allValues) {
692
717
  const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
693
718
  let val = mp.statics[0];
694
719
  for (let j = 0; j < mp.group.length; j++) {
695
- val += String((allValues ? allValues[mp.group[j]] : value) ?? '');
720
+ const piece = allValues ? allValues[mp.group[j]] : value;
721
+ // #1154: same function guard for each piece of a mixed attribute.
722
+ assertNotFunctionActionAttr(piece, part.name, part.el.localName);
723
+ val += String(piece ?? '');
696
724
  val += mp.statics[j + 1] || '';
697
725
  }
698
726
  part.el.setAttribute(part.name, val);