@webjsdev/core 0.7.50 → 0.7.53
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/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -20
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +3 -3
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.js +2 -1896
- package/src/css.d.ts +9 -0
- package/src/escape.d.ts +2 -0
- package/src/html.d.ts +9 -0
- package/src/render-client/parts.js +2039 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +938 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +206 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +182 -0
- package/src/router-client/fetch-apply.js +432 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +952 -0
- package/src/router-client/prefetch.js +831 -0
- package/src/router-client/scroll.js +308 -0
- package/src/router-client/snapshot-cache.js +113 -0
- package/src/router-client/state.js +155 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +454 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +11 -2
- package/src/router-client.js +78 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
- package/src/suspense.d.ts +9 -0
- package/src/websocket-client.d.ts +22 -0
|
@@ -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
|
+
}
|