@webjsdev/core 0.7.49 → 0.7.51

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.
Files changed (61) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +21 -21
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +1 -1
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/attribute-reader.js +178 -0
  12. package/src/component/lifecycle.js +1481 -0
  13. package/src/component/reactive.js +171 -0
  14. package/src/component/server-element.js +248 -0
  15. package/src/component.d.ts +10 -0
  16. package/src/component.js +2 -1931
  17. package/src/html-entities.js +2179 -0
  18. package/src/render-client/parts.js +2007 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2939
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +923 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2334
  30. package/src/router-client/anchors.js +44 -0
  31. package/src/router-client/boundaries.js +221 -0
  32. package/src/router-client/constants.js +187 -0
  33. package/src/router-client/diagnostics.js +377 -0
  34. package/src/router-client/dom-differ.js +709 -0
  35. package/src/router-client/dom-parse.js +150 -0
  36. package/src/router-client/events.js +139 -0
  37. package/src/router-client/fetch-apply.js +387 -0
  38. package/src/router-client/form-encoder.js +135 -0
  39. package/src/router-client/frames.js +329 -0
  40. package/src/router-client/head-merge.js +344 -0
  41. package/src/router-client/nav-error.js +105 -0
  42. package/src/router-client/navigator.js +781 -0
  43. package/src/router-client/prefetch.js +824 -0
  44. package/src/router-client/scroll.js +259 -0
  45. package/src/router-client/snapshot-cache.js +97 -0
  46. package/src/router-client/state.js +92 -0
  47. package/src/router-client/stream.js +206 -0
  48. package/src/router-client/swap.js +451 -0
  49. package/src/router-client/upgrade.js +261 -0
  50. package/src/router-client/view-transition.js +223 -0
  51. package/src/router-client.d.ts +10 -1
  52. package/src/router-client.js +75 -5345
  53. package/src/slot/assignment.js +304 -0
  54. package/src/slot/interception.js +558 -0
  55. package/src/slot/polyfills.js +365 -0
  56. package/src/slot/project.js +432 -0
  57. package/src/slot/sensors.js +303 -0
  58. package/src/slot/state.js +245 -0
  59. package/src/slot/symbols.js +85 -0
  60. package/src/slot.js +7 -2199
  61. package/src/webjs-config.d.ts +13 -2
@@ -0,0 +1,525 @@
1
+ import { MARKER } from '../html.js';
2
+ import { BINDING_PREFIXES, isBindingPrefix } from '../binding-prefixes.js';
3
+ import { isSubmitterReflectedProp } from '../form-action.js';
4
+ import { LIGHT_SLOT_ATTR } from '../slot.js';
5
+
6
+ /** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[], formActions: FormActionRecord[] | null }>} */
7
+ export const templateCache = new WeakMap();
8
+ /**
9
+ * Submitters this renderer stamped with an identity, so a later release removes
10
+ * only the framework's own `name` / `value` and never an author's.
11
+ * @type {WeakMap<Element, string>}
12
+ */
13
+ export const submitterActionBindings = new WeakMap();
14
+ export const INSTANCE = Symbol.for('webjs.instance');
15
+
16
+ /**
17
+ * @typedef {{
18
+ * kind: 'child' | 'attr' | 'attr-mixed' | 'event' | 'prop' | 'bool' | 'element' | 'slot' | 'noop',
19
+ * path: number[],
20
+ * name?: string,
21
+ * statics?: string[],
22
+ * group?: number[],
23
+ * }} PartDescriptor
24
+ *
25
+ * @typedef {{
26
+ * strings: TemplateStringsArray | string[],
27
+ * bound: BoundPart[],
28
+ * lastValues: unknown[],
29
+ * startNode: Comment,
30
+ * endNode: Comment,
31
+ * }} TemplateInstance
32
+ *
33
+ * @typedef {
34
+ * | { kind: 'child', marker: Comment, child?: TemplateInstance | ChildNode[] }
35
+ * | { kind: 'attr', el: Element, name: string }
36
+ * | { kind: 'attr-mixed', el: Element, name: string, statics: string[], group: number[] }
37
+ * | { kind: 'event', el: Element, name: string, handler: ((e: Event) => void) | null, dispatcher: (e: Event) => void }
38
+ * | { kind: 'prop', el: Element, name: string }
39
+ * | { kind: 'bool', el: Element, name: string }
40
+ * | { kind: 'element', el: Element, lastTarget?: any }
41
+ * | { kind: 'slot', slotEl: HTMLSlotElement, applied: boolean }
42
+ * | { kind: 'noop' }
43
+ * } BoundPart
44
+ */
45
+
46
+ /** @param {import('../html.js').TemplateResult} tr */
47
+ export function compile(tr) {
48
+ const { strings } = tr;
49
+ let cached = templateCache.get(strings);
50
+ if (cached) return cached;
51
+
52
+ /** @type {PartDescriptor[]} */
53
+ const parts = [];
54
+ let html = '';
55
+ let state = 'text';
56
+ let attrName = '';
57
+ let attrStart = 0;
58
+ let attrQuote = '';
59
+ let commentDashes = 0;
60
+ /** @type {{ name: string, firstPartIdx: number } | null} */
61
+ let mixedAttr = null;
62
+ let currentTag = '';
63
+ let rawTail = '';
64
+
65
+ for (let i = 0; i < strings.length; i++) {
66
+ const s = strings[i];
67
+ for (let j = 0; j < s.length; j++) {
68
+ const c = s[j];
69
+ switch (state) {
70
+ case 'text':
71
+ html += c;
72
+ if (c === '<') state = 'tag-open';
73
+ break;
74
+ case 'tag-open':
75
+ html += c;
76
+ if (c === '!') state = 'bang-1';
77
+ else if (c === '/') { state = 'tag-name'; currentTag = ''; }
78
+ else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
79
+ else state = 'text';
80
+ break;
81
+ case 'bang-1':
82
+ html += c;
83
+ state = c === '-' ? 'bang-dash' : 'tag-name';
84
+ break;
85
+ case 'bang-dash':
86
+ html += c;
87
+ if (c === '-') { state = 'comment'; commentDashes = 0; }
88
+ else state = 'tag-name';
89
+ break;
90
+ case 'comment':
91
+ html += c;
92
+ if (c === '-') commentDashes += 1;
93
+ else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
94
+ else commentDashes = 0;
95
+ break;
96
+ case 'tag-name':
97
+ html += c;
98
+ if (c === '>') {
99
+ state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
100
+ if (state === 'rawtext') rawTail = '';
101
+ } else if (/\s/.test(c)) state = 'in-tag';
102
+ else currentTag += c.toLowerCase();
103
+ break;
104
+ case 'in-tag':
105
+ html += c;
106
+ if (c === '>') {
107
+ state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
108
+ if (state === 'rawtext') rawTail = '';
109
+ } else if (!/\s/.test(c) && c !== '/') {
110
+ state = 'attr-name';
111
+ attrName = c;
112
+ attrStart = html.length - 1;
113
+ }
114
+ break;
115
+ case 'rawtext':
116
+ html += c;
117
+ rawTail = (rawTail + c.toLowerCase()).slice(-9);
118
+ if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
119
+ state = 'text';
120
+ rawTail = '';
121
+ currentTag = '';
122
+ }
123
+ break;
124
+ case 'attr-name':
125
+ if (c === '=') { state = 'after-eq'; html += c; }
126
+ else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
127
+ else if (c === '>') { state = 'text'; attrName = ''; html += c; }
128
+ else { attrName += c; html += c; }
129
+ break;
130
+ case 'after-eq':
131
+ if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; html += c; }
132
+ else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
133
+ else if (c === '>') { state = 'text'; attrName = ''; html += c; }
134
+ else { state = 'attr-unquoted'; html += c; }
135
+ break;
136
+ case 'attr-unquoted':
137
+ if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
138
+ else if (c === '>') { state = 'text'; attrName = ''; html += c; }
139
+ else html += c;
140
+ break;
141
+ case 'attr-quoted':
142
+ html += c;
143
+ if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
144
+ break;
145
+ case 'skip-attr':
146
+ // Consume mixed-attribute chars without appending to html.
147
+ // The attribute was replaced with a sentinel on the first hole.
148
+ if (c === attrQuote) {
149
+ // Closing quote: finalize the attr-mixed part.
150
+ if (mixedAttr) {
151
+ const idx0 = mixedAttr.firstPartIdx;
152
+ const group = [];
153
+ for (let k = idx0; k < parts.length; k++) {
154
+ if (parts[k].kind === 'noop' || parts[k].kind === 'attr-mixed') group.push(k);
155
+ }
156
+ // Build statics from the template strings array.
157
+ // For `attr="a ${x} b ${y} c"`, group=[idx0,idx1].
158
+ // statics[0] = tail of strings[idx0] after the `="`
159
+ // statics[1] = strings[idx1] (between holes)
160
+ // statics[n] = prefix of strings[last+1] up to closing quote
161
+ const statics = [];
162
+ const s0 = strings[group[0]];
163
+ const qp = s0.lastIndexOf(attrQuote);
164
+ statics.push(qp >= 0 ? s0.slice(qp + 1) : s0);
165
+ for (let k = 1; k < group.length; k++) {
166
+ statics.push(strings[group[k]]);
167
+ }
168
+ const sLast = strings[group[group.length - 1] + 1];
169
+ const eq = sLast.indexOf(attrQuote);
170
+ statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast);
171
+
172
+ parts[idx0] = {
173
+ kind: 'attr-mixed',
174
+ path: [],
175
+ name: mixedAttr.name,
176
+ statics,
177
+ group,
178
+ };
179
+ // The mixed attribute is rebuilt from ALL its holes' values, but
180
+ // it is anchored at a single part (group[0]). The later holes stay
181
+ // `noop`, so a change confined to one of them would be skipped by
182
+ // updateInstance's per-hole dirty-check and the attribute would go
183
+ // stale. Point every non-anchor member back at the anchor so a
184
+ // change to any hole re-applies the whole attribute.
185
+ for (let m = 1; m < group.length; m++) {
186
+ parts[group[m]] = { kind: 'noop', path: [], mixedAnchor: idx0 };
187
+ }
188
+ mixedAttr = null;
189
+ }
190
+ state = 'in-tag';
191
+ attrName = '';
192
+ }
193
+ break;
194
+ }
195
+ }
196
+
197
+ if (i < strings.length - 1) {
198
+ const partIdx = parts.length;
199
+ if (state === 'comment') {
200
+ // Holes inside <!-- ... --> are dropped. Comments are inert and
201
+ // the compile cache is keyed on `strings`, so per-render values
202
+ // can't be baked in anyway.
203
+ commentDashes = 0;
204
+ parts.push({ kind: 'noop', path: [] });
205
+ continue;
206
+ }
207
+ if (state === 'rawtext') {
208
+ // Inside <script>/<style>: per-render interpolation isn't supported;
209
+ // the compile cache would lock in whatever was first rendered. The
210
+ // hole is dropped and authors should set body text via a child part
211
+ // outside the raw-text container, or inline style/script directly.
212
+ rawTail = '';
213
+ parts.push({ kind: 'noop', path: [] });
214
+ continue;
215
+ }
216
+ if (state === 'text') {
217
+ // Child hole: insert a comment marker. Use bracketed markers so we can
218
+ // later walk all comments and find ours without ambiguity.
219
+ html += `<!--${MARKER}${partIdx}-->`;
220
+ parts.push({ kind: 'child', path: [] });
221
+ } else if (state === 'in-tag') {
222
+ // Element-position hole: `<tag ${expr}>`. Used by the `ref` directive
223
+ // (and any future element-bound directive). Emit a sentinel attribute
224
+ // on the current open tag; at bind time the attribute is stripped
225
+ // and the element is captured into the part.
226
+ const sentinel = `data-${MARKER}${partIdx}`;
227
+ html += `${sentinel}=""`;
228
+ parts.push({ kind: 'element', path: [] });
229
+ } else if (state === 'after-eq') {
230
+ const prefix = attrName[0];
231
+ const name = attrName.slice(1);
232
+ if (isBindingPrefix(prefix)) {
233
+ // Strip the attribute name+"=" from html and add a sentinel attr.
234
+ html = html.slice(0, attrStart);
235
+ const kind = BINDING_PREFIXES[prefix];
236
+ const sentinel = `data-${MARKER}${partIdx}`;
237
+ html += `${sentinel}=""`;
238
+ parts.push({ kind, path: [], name });
239
+ } else {
240
+ // Regular attribute: rewrite to `attrName="__MARKER__"` and parse as attr.
241
+ html = html.slice(0, attrStart);
242
+ const sentinel = `data-${MARKER}${partIdx}`;
243
+ html += `${sentinel}=""`;
244
+ parts.push({ kind: 'attr', path: [], name: attrName });
245
+ }
246
+ state = 'in-tag';
247
+ attrName = '';
248
+ } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
249
+ // First hole inside a quoted attribute value: start mixed-attr tracking.
250
+ // Replace the entire attribute with a sentinel (same as regular attr).
251
+ html = html.slice(0, attrStart);
252
+ const sentinel = `data-${MARKER}${partIdx}`;
253
+ html += `${sentinel}=""`;
254
+ mixedAttr = { name: attrName, firstPartIdx: partIdx };
255
+ parts.push({ kind: 'noop', path: [] }); // patched to attr-mixed at close-quote
256
+ state = 'skip-attr';
257
+ } else if (state === 'skip-attr') {
258
+ // Subsequent hole in the same mixed attribute.
259
+ parts.push({ kind: 'noop', path: [] });
260
+ }
261
+ }
262
+ }
263
+
264
+ const templateEl = document.createElement('template');
265
+ templateEl.innerHTML = html;
266
+
267
+ // Mark every <slot> in the template for framework projection and
268
+ // register a SLOT part for each so the slot-apply step can find them on
269
+ // clones. This runs BEFORE assignPaths so the sentinel attributes the
270
+ // discovery step adds are picked up in the same path-recording walk.
271
+ discoverSlots(templateEl.content, parts);
272
+
273
+ // Walk the parsed fragment and record DOM paths for each part. It also
274
+ // returns the per-template form-action records (#1155), null when the
275
+ // template contains no bound-form candidate, which is the overwhelmingly
276
+ // common case and keeps the reconcile out of every other template's path.
277
+ const formActions = assignPaths(templateEl.content, parts);
278
+
279
+ cached = { templateEl, parts, formActions };
280
+ templateCache.set(strings, cached);
281
+ return cached;
282
+ }
283
+
284
+ /**
285
+ * Walk the compiled template content for <slot> elements (the static ones
286
+ * written into the template, not dynamically-inserted ones). For each:
287
+ * 1. Add the `data-webjs-light` attribute so slot.js's polyfilled APIs
288
+ * recognise it as a framework-managed light-DOM slot.
289
+ * 2. Add a sentinel attribute (`data-MARKER<idx>`) so the subsequent
290
+ * assignPaths walk records the slot's path into the new SLOT part.
291
+ * 3. Move the slot's authored children into a `fallbackTemplate`
292
+ * DocumentFragment stored on the PartDescriptor. The slot in the
293
+ * cached template becomes empty, so every clone starts empty too.
294
+ * bindPart clones a fresh fallback fragment per instance from this
295
+ * template, giving each instance an independent fallback supply
296
+ * that slot.js swaps in via the SLOT_FALLBACK_FRAG symbol.
297
+ *
298
+ * Fallback content with template holes (`<slot>fallback ${x}</slot>`)
299
+ * is captured as a static-HTML snapshot of the template state at
300
+ * compile time. Dynamic holes inside fallback content are not
301
+ * re-bound per instance in v1; authors should put dynamic content
302
+ * outside the slot.
303
+ *
304
+ * @param {DocumentFragment} root
305
+ * @param {PartDescriptor[]} parts
306
+ */
307
+ function discoverSlots(root, parts) {
308
+ const slots = root.querySelectorAll('slot');
309
+ for (const slot of slots) {
310
+ slot.setAttribute(LIGHT_SLOT_ATTR, '');
311
+ const partIdx = parts.length;
312
+ slot.setAttribute(`data-${MARKER}${partIdx}`, '');
313
+ parts.push({ kind: 'slot', path: [] });
314
+ }
315
+ // NOTE: fallback content stays IN the slot's children in the cached
316
+ // template. Each clone gets its own copy. For shadow-DOM components,
317
+ // native browser projection uses those children as fallback content
318
+ // when no light child matches. For light-DOM components, the slot's
319
+ // apply step (run after the cloned template is in the live tree)
320
+ // moves the cloned fallback into a per-instance holding fragment
321
+ // owned by slot.js, so the slot is empty and ready to receive
322
+ // projected children. Doing the strip at apply time, not at compile
323
+ // time, lets a single cached template serve both DOM modes.
324
+ }
325
+
326
+ /**
327
+ * Walk the template fragment and record the path (chain of child indices) to
328
+ * each part's anchor node. We use marker comments for child parts and sentinel
329
+ * attributes for everything else.
330
+ *
331
+ * @param {DocumentFragment} root
332
+ * @param {PartDescriptor[]} parts
333
+ */
334
+ function assignPaths(root, parts) {
335
+ /** @type {number[]} */
336
+ const path = [];
337
+ /** @type {FormActionRecord[]} */
338
+ const formActions = [];
339
+
340
+ /** @param {Node} node */
341
+ function visit(node) {
342
+ for (let i = 0; i < node.childNodes.length; i++) {
343
+ const child = node.childNodes[i];
344
+ path.push(i);
345
+ // Comment marker?
346
+ if (child.nodeType === 8) {
347
+ const txt = /** @type Comment */ (child).data;
348
+ if (txt.startsWith(MARKER)) {
349
+ const idx = Number(txt.slice(MARKER.length));
350
+ if (parts[idx] && parts[idx].kind === 'child') {
351
+ parts[idx].path = path.slice();
352
+ }
353
+ }
354
+ } else if (child.nodeType === 1) {
355
+ const el = /** @type Element */ (child);
356
+ // Sentinel attribute?
357
+ const toRemove = [];
358
+ /** Every part bound to THIS element, which only this walk can see. */
359
+ /** @type {{ idx: number, kind: string, name: string }[]} */
360
+ const onEl = [];
361
+ for (const attr of el.attributes) {
362
+ if (attr.name.startsWith(`data-${MARKER}`)) {
363
+ const idx = Number(attr.name.slice(`data-${MARKER}`.length));
364
+ if (parts[idx] && parts[idx].kind !== 'child') {
365
+ parts[idx].path = path.slice();
366
+ onEl.push({ idx, kind: parts[idx].kind, name: parts[idx].name || '' });
367
+ }
368
+ toRemove.push(attr.name);
369
+ }
370
+ }
371
+
372
+ // #1155 / #1207: record what a bound form or submitter needs, while the
373
+ // STATIC attributes and the element's part indices are both still
374
+ // visible. This is the only point in compilation with that whole view,
375
+ // and it runs once per template, so the record is a constant rather
376
+ // than runtime memory that can drift out of step with the DOM. It is
377
+ // also the only place a submitter's own `name` is still legible: by
378
+ // reconcile time the renderer has written the identity over it.
379
+ if (el.localName === 'form' || el.localName === 'button' || el.localName === 'input') {
380
+ const rec = buildFormActionRecord(el, onEl, parts);
381
+ if (rec) formActions.push(rec);
382
+ }
383
+
384
+ for (const name of toRemove) {
385
+ el.removeAttribute(name);
386
+ }
387
+ visit(child);
388
+ }
389
+ path.pop();
390
+ }
391
+ }
392
+
393
+ visit(root);
394
+ return formActions.length ? formActions : null;
395
+ }
396
+
397
+ /**
398
+ * Build the compile-time record for one `<form>` carrying an `action` hole, or
399
+ * one submitter carrying a `formaction` hole.
400
+ *
401
+ * A CANDIDATE, not a decision: `<form action=${maybeString}>` compiles exactly
402
+ * like a bound one, so whether the form is really bound is a runtime fact the
403
+ * reconcile decides from the value. Everything recorded here is a property of
404
+ * the TEMPLATE, which is why it cannot go stale the way remembering "did the
405
+ * bind supply this attribute" at runtime did.
406
+ *
407
+ * `encoding` folds into `enctype` for a PROPERTY binding only: it is a legacy
408
+ * IDL alias, so `.encoding=` writes the enctype content attribute. As a content
409
+ * attribute `encoding=` is inert (a browser's `form.encoding` reads back
410
+ * `enctype`), and SSR ignores it, so folding the attribute spelling too would
411
+ * make the client honour a value the server never saw.
412
+ *
413
+ * The submitter fields (`authoredName` / `authoredValue` / `authoredForm`) are
414
+ * read off the COMPILED template rather than the live element, for the same
415
+ * reason: by reconcile time the renderer has written its own `name` / `value`
416
+ * onto a bound submitter, so the live element can no longer say which of them
417
+ * the author wrote. A hole-provided `name=${n}` leaves no attribute on the
418
+ * compiled template at all, which is why `nameParts` is tracked separately.
419
+ *
420
+ * @param {Element} el
421
+ * @param {{ idx: number, kind: string, name: string }[]} onEl parts bound to `el`
422
+ * @param {PartDescriptor[]} parts
423
+ * @returns {FormActionRecord | null}
424
+ */
425
+ function buildFormActionRecord(el, onEl, parts) {
426
+ const isForm = el.localName === 'form';
427
+ const targetAttr = isForm ? 'action' : 'formaction';
428
+ // The submission pair, named per element (#1307). A form declares `method` /
429
+ // `enctype`; a submitter declares `formmethod` / `formenctype`, and a bound
430
+ // one receives them from the renderer exactly as a bound form does.
431
+ const methodAttr = isForm ? 'method' : 'formmethod';
432
+ const enctypeAttr = isForm ? 'enctype' : 'formenctype';
433
+ const actionParts = onEl.filter((p) => p.kind === 'attr' && p.name.toLowerCase() === targetAttr);
434
+ if (!actionParts.length) return null;
435
+
436
+ /** @type {FormAttrPart[]} */
437
+ const methodParts = [];
438
+ /** @type {FormAttrPart[]} */
439
+ const enctypeParts = [];
440
+ /** Prop bindings that cannot converge with SSR; refused when actually bound. */
441
+ const propAttrs = [];
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
446
+ // only "is there a part called name" therefore refused templates SSR renders
447
+ // happily, which is the render-on-the-server-throw-on-hydration direction.
448
+ // The kinds travel with the record and `reconcileFormActions` resolves them.
449
+ const nameParts = isForm ? [] : onEl
450
+ .filter((p) => p.name.toLowerCase() === 'name'
451
+ && (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool'))
452
+ .map((p) => ({ i: p.idx, kind: p.kind }));
453
+ const authoredName = !isForm && el.hasAttribute('name');
454
+ const authoredForm = !isForm && el.hasAttribute('form');
455
+ const authoredValue = !isForm && el.hasAttribute('value');
456
+ // `value` holes, recorded with their kind for the same reason as `name`: a
457
+ // FALSY boolean hole emits nothing at SSR, so counting the part's mere
458
+ // presence refused a template the server renders happily.
459
+ const valueParts = isForm ? [] : onEl
460
+ .filter((p) => p.name.toLowerCase() === 'value'
461
+ && (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool'))
462
+ .map((p) => ({ i: p.idx, kind: p.kind }));
463
+
464
+ for (const p of onEl) {
465
+ const name = String(p.name).toLowerCase();
466
+ if (p.kind === 'prop') {
467
+ // On a FORM, the reflected attributes that decide submittability. On a
468
+ // SUBMITTER, the ones that carry the identity or override the submission
469
+ // (#1207). Either way a `.prop` is dropped at SSR and written to the
470
+ // attribute in the browser, so it can never converge.
471
+ if (isForm) {
472
+ if (name === 'method' || name === 'enctype' || name === 'encoding') propAttrs.push(p.name);
473
+ } else if (isSubmitterReflectedProp(name)) {
474
+ propAttrs.push(p.name);
475
+ }
476
+ continue;
477
+ }
478
+ // #1307: a bound submitter carries its OWN submission attributes, so the
479
+ // pair to capture depends on the element. `formmethod` / `formenctype` on a
480
+ // button are exactly what `method` / `enctype` are on a form, and they flow
481
+ // through the same resolution below untouched.
482
+ if (name !== methodAttr && name !== enctypeAttr) continue;
483
+ if (p.kind !== 'attr' && p.kind !== 'attr-mixed' && p.kind !== 'bool') continue;
484
+ const d = /** @type any */ (parts[p.idx]);
485
+ /** @type {FormAttrPart} */
486
+ const entry = { i: p.idx, kind: p.kind };
487
+ // A mixed attribute's value is `statics[0] + v0 + statics[1] + ...`, so the
488
+ // pieces have to travel with the record; reading the anchor's value alone
489
+ // is right only when the statics are empty.
490
+ if (p.kind === 'attr-mixed') { entry.statics = d.statics || []; entry.group = d.group || []; }
491
+ // Compared against `methodAttr`, NOT the literal 'method'. On a submitter
492
+ // the pair is `formmethod` / `formenctype`, so a literal comparison sent
493
+ // every hole-provided `formmethod` into `enctypeParts`: the client then
494
+ // resolved it as the enctype, refused `formenctype="post"`, and threw on
495
+ // hydration for a template SSR renders happily. That is the
496
+ // render-on-the-server, crash-in-the-browser direction this module treats
497
+ // as the one unacceptable failure.
498
+ (name === methodAttr ? methodParts : enctypeParts).push(entry);
499
+ }
500
+
501
+ return {
502
+ isForm,
503
+ tag: el.localName,
504
+ // EVERY action hole, not just the first. SSR refuses two holes whenever
505
+ // ANY of them resolves to a bound action, so recording only the first would
506
+ // let `<form action=${'/legacy'} action=${boundFn}>` slip through the
507
+ // client's release path while SSR throws on the same template.
508
+ actionIdxs: actionParts.map((p) => p.idx),
509
+ duplicateAction: actionParts.length > 1,
510
+ // A static `action="..."` / `formaction="..."` surviving alongside the hole.
511
+ // The compiled template holds the hole as a sentinel attribute, so anything
512
+ // read back under the real name here is the author's own second one.
513
+ staticAction: el.getAttribute(targetAttr) != null,
514
+ authoredName,
515
+ authoredValue,
516
+ authoredForm,
517
+ propAttrs,
518
+ nameParts,
519
+ valueParts,
520
+ staticMethod: el.getAttribute(methodAttr),
521
+ staticEnctype: el.getAttribute(enctypeAttr),
522
+ methodParts,
523
+ enctypeParts,
524
+ };
525
+ }