@webjsdev/core 0.7.62 → 0.7.64

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.62",
3
+ "version": "0.7.64",
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",
@@ -40,3 +40,50 @@ export const BINDING_PREFIXES = Object.freeze({
40
40
  export function isBindingPrefix(ch) {
41
41
  return Object.prototype.hasOwnProperty.call(BINDING_PREFIXES, ch);
42
42
  }
43
+
44
+ /**
45
+ * The HTML boolean attributes: present means on, absent means off, and the
46
+ * value is meant to be empty. A boolean in a PLAIN hole on one of these renders
47
+ * exactly like `?attr` (#1579), so `checked=${isDefault}` cannot serve the
48
+ * `checked="false"` that HTML reads as checked. Lowercase, from the HTML spec's
49
+ * attribute index.
50
+ */
51
+ const BOOLEAN_ATTRIBUTES = new Set([
52
+ 'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'controls',
53
+ 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'inert',
54
+ 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate',
55
+ 'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected',
56
+ 'shadowrootclonable', 'shadowrootdelegatesfocus', 'shadowrootserializable',
57
+ ]);
58
+
59
+ /**
60
+ * What a PLAIN attribute hole (`name=${value}`, unquoted, the whole value)
61
+ * writes, or `null` when the attribute is omitted. The one rule both renderers
62
+ * follow, so the server never serves an attribute the client then removes
63
+ * (#1573, #1579):
64
+ *
65
+ * - `null` / `undefined` omit the attribute. This is the "omit" value, so a
66
+ * conditional attribute is `aria-current=${active ? 'page' : null}`.
67
+ * - `false` omits it too, EXCEPT on an `aria-*` attribute, where it writes
68
+ * `"false"`. For a tri-state ARIA attribute (`aria-expanded`,
69
+ * `aria-pressed`, `aria-checked`) `"false"` and absent mean different
70
+ * things, so the value an author passes is the value the user agent gets.
71
+ * - `true` on an HTML boolean attribute (`checked`, `selected`, `disabled`,
72
+ * and the rest) writes the empty value, exactly as `?attr=${true}` does. Anywhere
73
+ * else it is `"true"`.
74
+ * - anything else is `String(value)`.
75
+ *
76
+ * A quoted or mixed value (`title="${x}"`, `class="a ${x}"`) is not a plain
77
+ * hole: the author wrote the attribute statically, so it stays and a nullish
78
+ * piece reads as empty text.
79
+ *
80
+ * @param {string} name the attribute name as written
81
+ * @param {unknown} value the hole's value (`live()` already unwrapped)
82
+ * @returns {string | null}
83
+ */
84
+ export function attrHoleValue(name, value) {
85
+ if (value == null) return null;
86
+ if (value === false) return /^aria-/i.test(name) ? 'false' : null;
87
+ if (value === true && BOOLEAN_ATTRIBUTES.has(String(name).toLowerCase())) return '';
88
+ return String(value);
89
+ }
@@ -11,6 +11,7 @@ import {
11
11
  SLOT_FALLBACK_FRAG, SLOT_STATE, SLOT_OWNER, applySlotAssignments, withRendererWrites,
12
12
  } from '../slot.js';
13
13
  import { compile, templateCache } from './template-compiler.js';
14
+ import { attrHoleValue } from '../binding-prefixes.js';
14
15
 
15
16
  /**
16
17
  * The container the in-progress `render()` is committing into, or null
@@ -203,7 +204,10 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
203
204
  applyChild(part, value, reconcileFormActionsCb);
204
205
  break;
205
206
  case 'attr': {
206
- if (value == null || value === false) part.el.removeAttribute(part.name);
207
+ // #1573: the shared rule, so this agrees with SSR. `null` / `undefined`
208
+ // remove, `false` removes except on aria-*, where it writes "false".
209
+ const written = attrHoleValue(part.name, value);
210
+ if (written === null) part.el.removeAttribute(part.name);
207
211
  else if (isBoundFormAction(value, part.name, part.el.localName)) {
208
212
  // #1155: the ONE supported form-action binding, applied to the live
209
213
  // form exactly as SSR wrote it. A component that ships re-renders its
@@ -220,7 +224,7 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
220
224
  // (mirrors the SSR guard, so a client re-render cannot write a
221
225
  // server action's source into the live DOM).
222
226
  assertNotFunctionActionAttr(value, part.name, part.el.localName);
223
- part.el.setAttribute(part.name, String(value));
227
+ part.el.setAttribute(part.name, written);
224
228
  }
225
229
  break;
226
230
  }
@@ -7,6 +7,7 @@ import {
7
7
  assertIdentifiableAction, FORM_ACTION_FIELD,
8
8
  } from '../form-action.js';
9
9
  import { isLive } from '../directives.js';
10
+ import { attrHoleValue } from '../binding-prefixes.js';
10
11
  import { RENDERING, SLOT_OWNER, SLOT_STATE, drainRendererBackstop, rescueAssignedNodes } from '../slot.js';
11
12
  import { compile, submitterActionBindings, INSTANCE } from './template-compiler.js';
12
13
  import {
@@ -132,13 +133,17 @@ function reconcileFormActions(formActions, bound, values) {
132
133
  rec,
133
134
  );
134
135
  } else {
135
- // What SSR would have emitted for this pass. An attribute hole always
136
- // emits (even `name=${null}`, as `name=""`); a boolean hole emits only
137
- // when truthy. Both identity channels ask the same question, through
138
- // one predicate, so they cannot drift apart again.
136
+ // What SSR would have emitted for this pass. A plain attribute hole
137
+ // emits unless `attrHoleValue` omits it (`null` / `undefined` /
138
+ // `false`, since `name` and `value` are not aria-*, #1573), a mixed
139
+ // one always emits, and a boolean hole emits only when truthy. Both
140
+ // identity channels ask the same question, through one predicate, so
141
+ // they cannot drift apart again.
139
142
  const emits = (parts) => parts.some((np) => (np.kind === 'bool'
140
143
  ? !!resolveHoleValue(values[np.i])
141
- : true));
144
+ : np.kind === 'attr'
145
+ ? attrHoleValue('name', resolveHoleValue(values[np.i])) !== null
146
+ : true));
142
147
  reconcileSubmitterAction(
143
148
  /** @type any */ (part.el), val, rec,
144
149
  emits(rec.nameParts), emits(rec.valueParts),
@@ -267,8 +272,10 @@ function effectiveFormAttr(attrParts, staticValue, values) {
267
272
  for (const p of attrParts) {
268
273
  if (p.kind === 'bool') return resolveHoleValue(values[p.i]) ? '' : ABSENT;
269
274
  if (p.kind === 'attr') {
270
- const v = resolveHoleValue(values[p.i]);
271
- return v == null ? '' : String(v);
275
+ // #1573: an omitted plain hole is ABSENT, exactly as SSR emits it, so
276
+ // `method=${null}` leaves the framework to supply `method="post"`.
277
+ const w = attrHoleValue('method', resolveHoleValue(values[p.i]));
278
+ return w === null ? ABSENT : w;
272
279
  }
273
280
  // A mixed attribute is the concatenation of its static pieces and EVERY one
274
281
  // of its holes, so the anchor's own value is only part of the answer.
@@ -440,9 +440,9 @@ function buildFormActionRecord(el, onEl, parts) {
440
440
  /** Prop bindings that cannot converge with SSR; refused when actually bound. */
441
441
  const propAttrs = [];
442
442
  // `name` holes on a submitter, recorded WITH their kind. Whether one occupies
443
- // the identity's channel is not a property of the hole alone: SSR emits
444
- // `name=""` for an attribute hole whatever it resolved to, but emits nothing
445
- // at all for a FALSY boolean hole and nothing for an `@name` listener. Asking
443
+ // the identity's channel is not a property of the hole alone: SSR omits an
444
+ // attribute hole that resolved to `null` / `undefined` / `false` (#1573),
445
+ // emits nothing for a FALSY boolean hole, and nothing for an `@name` listener. Asking
446
446
  // only "is there a part called name" therefore refused templates SSR renders
447
447
  // happily, which is the render-on-the-server-throw-on-hydration direction.
448
448
  // The kinds travel with the record and `reconcileFormActions` resolves them.
@@ -1,5 +1,5 @@
1
1
  import { html, isTemplate } from '../html.js';
2
- import { BINDING_PREFIXES } from '../binding-prefixes.js';
2
+ import { BINDING_PREFIXES, attrHoleValue } from '../binding-prefixes.js';
3
3
  import { escapeText, escapeAttr } from '../escape.js';
4
4
  import {
5
5
  assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp,
@@ -542,7 +542,12 @@ export async function renderTemplate(tr, ctx) {
542
542
  // #1154: never stringify a function into action=/formaction= (it
543
543
  // would serialize a server action's source into the served HTML).
544
544
  assertNotFunctionActionAttr(val, attrName, currentTag);
545
- out += `"${escapeAttr(String(val ?? ''))}"`;
545
+ // #1573: the client's rule, not a stringify. `null` / `undefined`
546
+ // (and `false` off an aria-* name) omit the attribute, which is
547
+ // already in `out` from `attrStart`, so slice it back off.
548
+ const written = attrHoleValue(attrName, val);
549
+ if (written === null) out = out.slice(0, attrStart);
550
+ else out += `"${escapeAttr(written)}"`;
546
551
  state = 'in-tag';
547
552
  attrName = '';
548
553
  }
@@ -922,7 +927,10 @@ export async function streamTemplate(tr, ctx, controller) {
922
927
  // `renderToStream(v, { ssr: false })`, which no page render uses, so
923
928
  // this covers the public API surface rather than a page leak.
924
929
  assertNotFunctionActionAttr(val, attrName, currentTag);
925
- buf += `"${escapeAttr(String(val ?? ''))}"`;
930
+ // #1573: the same omission rule as the buffered renderer above.
931
+ const written = attrHoleValue(attrName, val);
932
+ if (written === null) buf = buf.slice(0, attrStart);
933
+ else buf += `"${escapeAttr(written)}"`;
926
934
  state = 'in-tag';
927
935
  attrName = '';
928
936
  }
@@ -554,11 +554,15 @@ export function reconcileChildren(dst, src) {
554
554
  keyedLive.delete(k);
555
555
  continue;
556
556
  }
557
- // Positional match: same tag, same index, neither has a key.
557
+ // Positional match: same tag, same index, neither has a key. A form
558
+ // control must also be the SAME control (#1581): reusing the `email`
559
+ // input for an incoming `name` input carries the live typed value into
560
+ // the wrong field, because `value` is a live attribute the diff keeps.
558
561
  const livePeer = liveChildren[i];
559
562
  if (livePeer && livePeer.nodeType === 1 &&
560
563
  !keyOf(/** @type {Element} */ (livePeer)) &&
561
- /** @type {Element} */ (livePeer).tagName === /** @type {Element} */ (inc).tagName) {
564
+ /** @type {Element} */ (livePeer).tagName === /** @type {Element} */ (inc).tagName &&
565
+ sameControlIdentity(/** @type {Element} */ (livePeer), /** @type {Element} */ (inc))) {
562
566
  diffElementInPlace(/** @type {Element} */ (livePeer), /** @type {Element} */ (inc));
563
567
  finalNodes.push(livePeer);
564
568
  continue;
@@ -597,6 +601,25 @@ export function reconcileChildren(dst, src) {
597
601
  }
598
602
  }
599
603
 
604
+ /**
605
+ * Whether two same-tag elements may stand for each other in a positional
606
+ * match. Anything but a form control always may. An `<input>`, `<select>` or
607
+ * `<textarea>` must agree on `name` (and an input on `type`), since the
608
+ * differ keeps a reused control's live value and a mismatch would move typed
609
+ * text into another field (#1581).
610
+ *
611
+ * @param {Element} a
612
+ * @param {Element} b
613
+ * @returns {boolean}
614
+ */
615
+ function sameControlIdentity(a, b) {
616
+ const tag = a.localName;
617
+ if (tag !== 'input' && tag !== 'select' && tag !== 'textarea') return true;
618
+ if (a.getAttribute('name') !== b.getAttribute('name')) return false;
619
+ if (tag === 'input' && (a.getAttribute('type') || 'text').toLowerCase() !== (b.getAttribute('type') || 'text').toLowerCase()) return false;
620
+ return true;
621
+ }
622
+
600
623
  /**
601
624
  * Get the diff key for an element: `data-key` if present, else `id`.
602
625
  * Returns null for elements with no stable key.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Client router: keep what a person typed across a failed submission (#1581).
3
+ *
4
+ * A bound form that fails validation is answered with a 422 re-render, which
5
+ * the router applies in place. The differ keeps a control's live state only
6
+ * when it reuses the old node (a keyed match, or the same tag at the same
7
+ * index), so an error message inserted above the inputs shifts every position
8
+ * and the inputs come back as fresh server markup: empty, unless the page
9
+ * refilled each one from `actionData.values`. Pages forget a field often
10
+ * enough that the framework should not depend on it.
11
+ *
12
+ * So the router snapshots the submitted form's controls at submit time and,
13
+ * after a non-2xx response is applied, writes the typed state back into the
14
+ * re-rendered form. A control is restored only when its server-rendered
15
+ * DEFAULT (the `value` attribute, `checked`, an option's `selected`, a
16
+ * textarea's text) is the same as before the submission. A default that
17
+ * changed means the server rendered that control on purpose (refilled it,
18
+ * normalized it, reset it), and the server wins.
19
+ *
20
+ * Never restored: password inputs (re-entering one after a failure is the
21
+ * convention, and nothing the server renders can say otherwise), file inputs
22
+ * (a script cannot set them), hidden inputs (server-owned), and buttons. A
23
+ * form opts out with `data-preserve-values="false"`.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ const FORM_ACTION_FIELD = '__webjs_action';
29
+ const SKIP_TYPES = new Set(['password', 'file', 'hidden', 'submit', 'button', 'image', 'reset']);
30
+
31
+ /**
32
+ * @typedef {{
33
+ * name: string,
34
+ * kind: 'text' | 'check' | 'select',
35
+ * n: number,
36
+ * value?: string,
37
+ * def: string,
38
+ * cur: string,
39
+ * }} ControlState
40
+ * @typedef {{ form: HTMLFormElement, key: string, ord: number, controls: ControlState[] }} FormSnapshot
41
+ */
42
+
43
+ /**
44
+ * How a form is found again after a re-render: its `id`, else its bound
45
+ * action identity, else its `action` attribute, plus its position among the
46
+ * forms sharing that key.
47
+ *
48
+ * @param {Element} form
49
+ * @returns {string}
50
+ */
51
+ function formKey(form) {
52
+ if (form.id) return `id:${form.id}`;
53
+ const field = form.querySelector(`input[name="${FORM_ACTION_FIELD}"]`);
54
+ if (field) return `act:${field.getAttribute('value') || ''}`;
55
+ return `url:${form.getAttribute('action') || ''}`;
56
+ }
57
+
58
+ /** @param {Element} form @param {string} key */
59
+ function ordinalOf(form, key) {
60
+ let i = 0;
61
+ for (const f of document.querySelectorAll('form')) {
62
+ if (f === form) return i;
63
+ if (formKey(f) === key) i++;
64
+ }
65
+ return -1;
66
+ }
67
+
68
+ /** @param {Element} el */
69
+ function kindOf(el) {
70
+ const tag = el.localName;
71
+ if (tag === 'textarea') return 'text';
72
+ if (tag === 'select') return 'select';
73
+ if (tag !== 'input') return null;
74
+ const type = (el.getAttribute('type') || 'text').toLowerCase();
75
+ if (SKIP_TYPES.has(type)) return null;
76
+ return type === 'checkbox' || type === 'radio' ? 'check' : 'text';
77
+ }
78
+
79
+ /**
80
+ * The controls of `form`, in document order, each with its per-name ordinal.
81
+ * Uses `form.elements` so a control associated by `form="id"` outside the
82
+ * form element is included.
83
+ *
84
+ * @param {HTMLFormElement} form
85
+ * @returns {Array<{ el: any, name: string, kind: 'text' | 'check' | 'select', n: number }>}
86
+ */
87
+ function controlsOf(form) {
88
+ /** @type {Map<string, number>} */
89
+ const seen = new Map();
90
+ const out = [];
91
+ const list = form.elements ? [...form.elements] : [...form.querySelectorAll('input, textarea, select')];
92
+ for (const el of list) {
93
+ const name = el.getAttribute && el.getAttribute('name');
94
+ if (!name || name === FORM_ACTION_FIELD) continue;
95
+ const kind = kindOf(el);
96
+ if (!kind) continue;
97
+ // Radios and checkboxes are told apart by their value, so the ordinal
98
+ // counts within name + value; text controls count within the name.
99
+ const key = kind === 'check' ? `${name}\u0000${el.getAttribute('value') ?? 'on'}` : name;
100
+ const n = seen.get(key) || 0;
101
+ seen.set(key, n + 1);
102
+ out.push({ el, name, kind: /** @type any */ (kind), n });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** @param {any} el @param {'text' | 'check' | 'select'} kind */
108
+ function readState(el, kind) {
109
+ if (kind === 'check') return { def: String(!!el.defaultChecked), cur: String(!!el.checked) };
110
+ if (kind === 'select') {
111
+ const opts = [...el.options];
112
+ return {
113
+ def: JSON.stringify(opts.map((o) => o.defaultSelected)),
114
+ cur: JSON.stringify(opts.filter((o) => o.selected).map((o) => o.value)),
115
+ };
116
+ }
117
+ return { def: el.defaultValue ?? '', cur: el.value ?? '' };
118
+ }
119
+
120
+ /**
121
+ * Snapshot a form's controls, or null when the form opts out.
122
+ *
123
+ * @param {HTMLFormElement | null | undefined} form
124
+ * @returns {FormSnapshot | null}
125
+ */
126
+ export function captureFormState(form) {
127
+ if (!form || form.getAttribute('data-preserve-values') === 'false') return null;
128
+ const key = formKey(form);
129
+ const controls = controlsOf(form).map(({ el, name, kind, n }) => ({
130
+ name, kind, n,
131
+ ...(kind === 'check' ? { value: el.getAttribute('value') ?? 'on' } : {}),
132
+ ...readState(el, kind),
133
+ }));
134
+ if (!controls.length) return null;
135
+ return { form, key, ord: ordinalOf(form, key), controls };
136
+ }
137
+
138
+ /**
139
+ * Find the snapshot's form in the current document.
140
+ *
141
+ * @param {FormSnapshot} snap
142
+ * @returns {HTMLFormElement | null}
143
+ */
144
+ function findForm(snap) {
145
+ if (snap.form.isConnected) return snap.form;
146
+ let i = 0;
147
+ for (const f of document.querySelectorAll('form')) {
148
+ if (formKey(f) !== snap.key) continue;
149
+ if (i === snap.ord) return /** @type any */ (f);
150
+ i++;
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Write the snapshot's typed state back into the re-rendered form, control by
157
+ * control, wherever the server left the control's default unchanged.
158
+ *
159
+ * @param {FormSnapshot | null} snap
160
+ * @returns {number} how many controls were restored (for tests)
161
+ */
162
+ export function restoreFormState(snap) {
163
+ if (!snap) return 0;
164
+ const form = findForm(snap);
165
+ if (!form) return 0;
166
+ const byKey = new Map();
167
+ for (const c of controlsOf(form)) {
168
+ const k = c.kind === 'check'
169
+ ? `${c.name}\u0000${c.el.getAttribute('value') ?? 'on'}\u0000${c.n}`
170
+ : `${c.name}\u0000${c.n}`;
171
+ byKey.set(k, c);
172
+ }
173
+ let restored = 0;
174
+ for (const s of snap.controls) {
175
+ const k = s.kind === 'check' ? `${s.name}\u0000${s.value}\u0000${s.n}` : `${s.name}\u0000${s.n}`;
176
+ const c = byKey.get(k);
177
+ if (!c || c.kind !== s.kind) continue;
178
+ const now = readState(c.el, c.kind);
179
+ // The server rendered this control differently on purpose, so it wins.
180
+ if (now.def !== s.def) continue;
181
+ if (now.cur === s.cur) continue;
182
+ if (s.kind === 'check') c.el.checked = s.cur === 'true';
183
+ else if (s.kind === 'select') {
184
+ const want = new Set(JSON.parse(s.cur));
185
+ for (const o of c.el.options) o.selected = want.has(o.value);
186
+ } else c.el.value = s.cur;
187
+ restored++;
188
+ }
189
+ return restored;
190
+ }
@@ -13,6 +13,7 @@ import { parseHTML } from './dom-parse.js';
13
13
  import { onClick, onPopState, onSubmit } from './events.js';
14
14
  import { fetchAndApply } from './fetch-apply.js';
15
15
  import { clearFormBusy, markFormBusy } from './frames.js';
16
+ import { captureFormState, restoreFormState } from './form-state.js';
16
17
  import { clearPrefetchHover, clearPrefetchRefused, clearPrefetchViewTimers, onPrefetchIntent, onPrefetchOut, prefetchCache, prefetchClearAll, prefetchEvict, refreshPrefetchObservers, teardownPrefetchViewObserver } from './prefetch.js';
17
18
  // `restoreGeneration` is imported READ-ONLY: the deferred restore captures it
18
19
  // and re-compares after the frame, so it must be the live binding. Writes go
@@ -869,6 +870,10 @@ export async function performSubmission(href, method, body, frameId, form, opts)
869
870
  // (markFormBusy/clearFormBusy) keeps a superseded submit's teardown from
870
871
  // clearing the busy state a newer submit set.
871
872
  const busyForm = form ? markFormBusy(form, myToken, url.href) : null;
873
+ // #1581: what the person typed, taken before the request leaves, so a failed
874
+ // submission's in-place re-render can hand it back (see form-state.js). A
875
+ // safe-method submission is a search, not a write, and is left alone.
876
+ const typed = !isSafe ? captureFormState(form) : null;
872
877
  let outcomeOk = false;
873
878
  try {
874
879
  const outcome = await fetchAndApply(
@@ -884,6 +889,13 @@ export async function performSubmission(href, method, body, frameId, form, opts)
884
889
  { preserveScroll: !!(opts && opts.preserveScroll) },
885
890
  );
886
891
  outcomeOk = !!(outcome && outcome.ok);
892
+ // A non-2xx response applied in place is the failed-validation re-render.
893
+ // Restore after the swap COMMITS: under an async view transition the DOM
894
+ // changes a frame later than `fetchAndApply` returns.
895
+ if (typed && outcome && outcome.applied && !outcome.ok && myToken === currentNavigationToken) {
896
+ await _swapCommit;
897
+ if (myToken === currentNavigationToken) restoreFormState(typed);
898
+ }
887
899
  // Mutating submissions invalidate cached versions of other URLs -
888
900
  // do this *after* the response applies so the new page itself is
889
901
  // snapshotted on the next nav, not pre-emptively wiped. Clear the
package/src/routes.d.ts CHANGED
@@ -124,6 +124,8 @@ export interface PageProps<R extends string = string> {
124
124
  * Present ONLY on the re-render after a failed page `action` submission
125
125
  * (#244). `undefined` on a normal GET render. Read `actionData.fieldErrors`
126
126
  * / `actionData.values` to surface validation errors and repopulate inputs.
127
+ * On a failure `values` carries every submitted text field, with the action's
128
+ * own `values` on top (#1581).
127
129
  */
128
130
  actionData?: unknown;
129
131
  }