@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,388 @@
1
+ import { isTemplate, MARKER } from '../html.js';
2
+ import {
3
+ reconcileFormAction, isBoundFormAction, ABSENT, assertSubmitterHasNoName, assertSubmitterType,
4
+ assertSubmitterHasNoValue, assertSubmitterHasNoStaticFormAction,
5
+ assertSubmitterHasNoFormAttribute, assertSingleSubmitterAction, resolveBoundSubmitterAttrs,
6
+ applyResolvedAttr, releaseSubmitterAttrs, assertConvergentSubmitter, formActionId,
7
+ assertIdentifiableAction, FORM_ACTION_FIELD,
8
+ } from '../form-action.js';
9
+ import { isLive } from '../directives.js';
10
+ import { RENDERING, SLOT_OWNER, SLOT_STATE, drainRendererBackstop, rescueAssignedNodes } from '../slot.js';
11
+ import { compile, submitterActionBindings, INSTANCE } from './template-compiler.js';
12
+ import {
13
+ applyPart, bindPart, currentRenderRoot, findSlotHost, setCurrentRenderRoot, updateInstance,
14
+ } from './parts.js';
15
+
16
+ /**
17
+ * Render a value into a container, reusing DOM where possible.
18
+ *
19
+ * @param {unknown} value
20
+ * @param {Element | DocumentFragment | ShadowRoot} container
21
+ */
22
+ export function render(value, container) {
23
+ const host = /** @type any */ (container);
24
+ // Open the renderer-write window for the whole commit: every host-receiver
25
+ // write below (and in createInstance / updateInstance / clearInstance / all
26
+ // part commits they reach synchronously) then bypasses the slot interception
27
+ // that is patched onto a light host. This is the single discriminator
28
+ // between a renderer commit and an author write.
29
+ const prevRendering = host[RENDERING];
30
+ host[RENDERING] = true;
31
+ const prevRenderRoot = currentRenderRoot;
32
+ setCurrentRenderRoot(container);
33
+ try {
34
+ const prev = host[INSTANCE];
35
+
36
+ if (isTemplate(value)) {
37
+ const tr = /** @type {import('../html.js').TemplateResult} */ (value);
38
+ if (prev && prev.strings === tr.strings) {
39
+ updateInstance(prev, tr.values, reconcileFormActions);
40
+ return;
41
+ }
42
+ if (prev) clearInstance(prev, container);
43
+
44
+ // Light DOM hydration: if container has SSR content (marked by
45
+ // <!--webjs-hydrate-->), remove the marker and proceed with normal
46
+ // rendering. The content will be replaced with identical output -
47
+ // no visible flash because SSR and client render produce the same HTML.
48
+ const firstChild = container.firstChild;
49
+ if (firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate') {
50
+ firstChild.remove();
51
+ }
52
+
53
+ // Pre-set the symbol to an explicit null BEFORE the commit,
54
+ // UNCONDITIONALLY: if createInstance throws after its replaceChildren
55
+ // (e.g. inside the slot-part apply loop), the finally-drain must see
56
+ // "rendered, no instance" (discard the commit's records), never
57
+ // "never rendered" (fold and corrupt) and never a STALE cleared prev
58
+ // instance on the template-swap path (whose bookends would misclassify
59
+ // the half-committed new roots as unowned and fold them).
60
+ host[INSTANCE] = null;
61
+ const inst = createInstance(tr, container);
62
+ host[INSTANCE] = inst;
63
+ return;
64
+ }
65
+
66
+ // Non-template value: treat as a single text child.
67
+ if (prev) clearInstance(prev, container);
68
+ host[INSTANCE] = null;
69
+ container.replaceChildren();
70
+ if (value == null || value === false || value === true) return;
71
+ if (Array.isArray(value)) {
72
+ for (const v of value) {
73
+ const text = document.createTextNode(String(v ?? ''));
74
+ container.appendChild(text);
75
+ }
76
+ return;
77
+ }
78
+ container.appendChild(document.createTextNode(String(value)));
79
+ } finally {
80
+ setCurrentRenderRoot(prevRenderRoot);
81
+ host[RENDERING] = prevRendering;
82
+ // Outermost window closing: drain this commit's childList records off the
83
+ // slot backstop (drainRendererBackstop processes them with a renderer-output
84
+ // skip when an instance exists, else discards), so the backstop never folds
85
+ // renderer output. Mirrors withRendererWrites, used by the async paths.
86
+ if (!prevRendering) drainRendererBackstop(host);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Converge every candidate bound form and submitter in this template, after all
92
+ * of its parts have committed. A no-op for the overwhelming majority of
93
+ * templates, which carry no `<form action=${...}>` at all and therefore no
94
+ * record.
95
+ *
96
+ * FORMS FIRST, submitters second, regardless of the order the records were
97
+ * collected in. Nothing reads an enclosing form's boundness any more (#1307
98
+ * made a bound submitter self-sufficient), but the ordering still makes a
99
+ * form's RELEASE run before its submitters reconcile, so it is kept rather
100
+ * than churned. See the note in the body.
101
+ *
102
+ * @param {FormActionRecord[] | null} formActions
103
+ * @param {BoundPart[]} bound
104
+ * @param {unknown[]} values
105
+ */
106
+ function reconcileFormActions(formActions, bound, values) {
107
+ if (!formActions) return;
108
+ // Forms first, then submitters. The original motivation was a boundness read
109
+ // that is gone with #1307, but the ordering still makes a form's RELEASE run
110
+ // before its submitters reconcile, so it is kept rather than churned.
111
+ for (const pass of [true, false]) {
112
+ for (const rec of formActions) {
113
+ if (rec.isForm !== pass) continue;
114
+ // With more than one action hole, the BOUND one decides, whichever
115
+ // position it is written in. Picking `actionIdxs[0]` blindly would send
116
+ // `<form action=${'/url'} action=${boundFn}>` down the release path and
117
+ // ship the broken form SSR refuses outright.
118
+ let idx = rec.actionIdxs[0];
119
+ const targetAttr = rec.isForm ? 'action' : 'formaction';
120
+ for (const i of rec.actionIdxs) {
121
+ if (isBoundFormAction(resolveHoleValue(values[i]), targetAttr, rec.tag)) { idx = i; break; }
122
+ }
123
+ const part = bound[idx];
124
+ if (!part || !part.el) continue;
125
+ const val = resolveHoleValue(values[idx]);
126
+ if (rec.isForm) {
127
+ reconcileFormAction(
128
+ /** @type any */ (part.el),
129
+ val,
130
+ effectiveFormAttr(rec.methodParts, rec.staticMethod, values),
131
+ effectiveFormAttr(rec.enctypeParts, rec.staticEnctype, values),
132
+ rec,
133
+ );
134
+ } 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.
139
+ const emits = (parts) => parts.some((np) => (np.kind === 'bool'
140
+ ? !!resolveHoleValue(values[np.i])
141
+ : true));
142
+ reconcileSubmitterAction(
143
+ /** @type any */ (part.el), val, rec,
144
+ emits(rec.nameParts), emits(rec.valueParts),
145
+ effectiveFormAttr(rec.methodParts, rec.staticMethod, values),
146
+ effectiveFormAttr(rec.enctypeParts, rec.staticEnctype, values),
147
+ );
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Remove the identity channel previously injected into a submitter.
155
+ *
156
+ * Only ours is removed. `submitterActionBindings` remembers the elements this
157
+ * renderer stamped, and the `name` check covers an SSR'd button meeting its
158
+ * template for the first time on hydration. A button that never carried a
159
+ * binding is left alone, so an author's own `name` / `value` survives a
160
+ * re-render that happens to pass a non-action value.
161
+ *
162
+ * @param {Element} el
163
+ */
164
+ function releaseSubmitterAction(el) {
165
+ const injected = submitterActionBindings.has(el) || el.getAttribute('name') === FORM_ACTION_FIELD;
166
+ if (!injected) return;
167
+ if (el.getAttribute('name') === FORM_ACTION_FIELD) el.removeAttribute('name');
168
+ el.removeAttribute('value');
169
+ submitterActionBindings.delete(el);
170
+ }
171
+
172
+ /**
173
+ * Converge a live submitter on what SSR emitted for `<button formaction=${fn}>`
174
+ * (#1207, #1307): the `formaction` attribute gone, the identity in the button's
175
+ * own `name` / `value` pair, and the submission the framework supplies in its
176
+ * own `formmethod` / `formenctype`.
177
+ *
178
+ * There is no enclosing-form question here any more. It used to be the hard
179
+ * part of this function, asked once per element and BEST EFFORT because a
180
+ * fragment reconciling detached (a submitter inside a `repeat()` or an array
181
+ * item, whose form lives in the parent template) has no answer to give. With
182
+ * the submission attributes now on the button itself (#1307) the button needs
183
+ * nothing from the form, so the question stopped mattering and the whole
184
+ * asymmetry with SSR went with it.
185
+ *
186
+ * @param {Element} el
187
+ * @param {unknown} value
188
+ * @param {FormActionRecord} rec
189
+ * @param {boolean} emitsName whether SSR would emit a `name` attribute this pass
190
+ * @param {boolean} emitsValue whether SSR would emit a `value` attribute this pass
191
+ * @param {string | typeof ABSENT} authoredFormMethod what the template supplies for `formmethod`
192
+ * @param {string | typeof ABSENT} authoredFormEnctype what the template supplies for `formenctype`
193
+ */
194
+ function reconcileSubmitterAction(
195
+ el, value, rec, emitsName, emitsValue, authoredFormMethod, authoredFormEnctype,
196
+ ) {
197
+ const id = typeof value === 'function' ? formActionId(value) : null;
198
+ if (!id) {
199
+ // A function that was MEANT as an action still refuses, so a button never
200
+ // silently submits the form's action instead of its own.
201
+ if (typeof value === 'function') assertIdentifiableAction(null, el.localName);
202
+ releaseSubmitterAction(el);
203
+ // The submission attributes the framework supplied go with the identity, or
204
+ // a released button keeps a `formmethod` / `formenctype` SSR does not emit
205
+ // for the same template (#1307).
206
+ releaseSubmitterAttrs(el, authoredFormMethod, authoredFormEnctype, rec.propAttrs);
207
+ return;
208
+ }
209
+ // Template-shaped refusals first, from the compiled record, because the live
210
+ // element may already carry this renderer's own `name` / `value`.
211
+ assertSingleSubmitterAction(rec.duplicateAction, el.localName);
212
+ assertConvergentSubmitter(rec.propAttrs, el.localName);
213
+ if (rec.staticAction) assertSubmitterHasNoStaticFormAction(el.localName);
214
+ if (rec.authoredValue || emitsValue) assertSubmitterHasNoValue(el.localName);
215
+ if (rec.authoredName || emitsName) {
216
+ // Judged on the PART, not on what it resolved to this pass. `name=${null}`
217
+ // leaves no attribute here while SSR emits `name=""` beside the identity,
218
+ // so reading the live value back returned '' and waved through a template
219
+ // SSR refuses. The template supplying a `name` channel at all is the
220
+ // conflict, whatever today's value happens to be.
221
+ assertSubmitterHasNoName(el.getAttribute('name') || FORM_ACTION_FIELD, el.localName, false);
222
+ }
223
+ if (rec.authoredForm || el.hasAttribute('form')) assertSubmitterHasNoFormAttribute(el.localName);
224
+ assertSubmitterType(el.localName, el.getAttribute('type'));
225
+
226
+ // The submission decision, made from the TEMPLATE rather than by reading the
227
+ // DOM back, exactly as `reconcileFormAction` makes it for a bound form. The
228
+ // distinction the DOM cannot preserve is the same one: `?formmethod=${false}`
229
+ // and `formmethod=${null}` both leave no attribute, and SSR resolves them to
230
+ // opposite answers.
231
+ const resolved = resolveBoundSubmitterAttrs(
232
+ el.localName, authoredFormMethod, authoredFormEnctype,
233
+ );
234
+ el.removeAttribute('formaction');
235
+ el.setAttribute('name', FORM_ACTION_FIELD);
236
+ el.setAttribute('value', id);
237
+ // AFTER the identity, matching SSR's byte order: the identity replaces the
238
+ // `formaction=` hole in place and the submission pair is appended at the `>`.
239
+ // The differential parity suite compares these two renderers byte for byte,
240
+ // so the order is part of the contract, not a detail.
241
+ applyResolvedAttr(el, 'formmethod', authoredFormMethod, resolved.formMethod);
242
+ applyResolvedAttr(el, 'formenctype', authoredFormEnctype, resolved.formEnctype);
243
+ submitterActionBindings.set(el, id);
244
+ }
245
+
246
+ /* ================================================================
247
+ * Instance lifecycle
248
+ * ================================================================ */
249
+
250
+
251
+
252
+ /**
253
+ * Resolve what SSR would have emitted for one attribute of a candidate form.
254
+ *
255
+ * The per-kind rules mirror `render-server.js` exactly, which is the whole
256
+ * point: a boolean hole emits nothing when falsy (`if (val) out += name+'=""'`)
257
+ * while an attribute hole emits an EMPTY value for null (`String(val ?? '')`).
258
+ * Those two produce the same DOM and opposite verdicts, so only the template
259
+ * can tell them apart.
260
+ *
261
+ * @param {FormAttrPart[]} attrParts
262
+ * @param {string | null} staticValue
263
+ * @param {unknown[]} values
264
+ * @returns {string | typeof ABSENT}
265
+ */
266
+ function effectiveFormAttr(attrParts, staticValue, values) {
267
+ for (const p of attrParts) {
268
+ if (p.kind === 'bool') return resolveHoleValue(values[p.i]) ? '' : ABSENT;
269
+ if (p.kind === 'attr') {
270
+ const v = resolveHoleValue(values[p.i]);
271
+ return v == null ? '' : String(v);
272
+ }
273
+ // A mixed attribute is the concatenation of its static pieces and EVERY one
274
+ // of its holes, so the anchor's own value is only part of the answer.
275
+ const statics = p.statics || [];
276
+ const group = p.group || [];
277
+ let out = statics[0] || '';
278
+ for (let j = 0; j < group.length; j++) {
279
+ out += String(resolveHoleValue(values[group[j]]) ?? '');
280
+ out += statics[j + 1] || '';
281
+ }
282
+ return out;
283
+ }
284
+ return staticValue == null ? ABSENT : staticValue;
285
+ }
286
+
287
+ /**
288
+ * Unwrap a hole's value the same way `applyPart` does, so the reconcile judges
289
+ * what was actually committed. `live()` in particular wraps its value, and
290
+ * reading the wrapper would make a bound action look like a plain object.
291
+ *
292
+ * @param {unknown} v
293
+ * @returns {unknown}
294
+ */
295
+ function resolveHoleValue(v) {
296
+ return isLive(v) ? /** @type any */ (v).value : v;
297
+ }
298
+
299
+ /**
300
+ * @param {import('../html.js').TemplateResult} tr
301
+ * @param {Element | DocumentFragment | ShadowRoot} container
302
+ */
303
+ function createInstance(tr, container) {
304
+ const { templateEl, parts, formActions } = compile(tr);
305
+ const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
306
+ // Bookend markers bound the instance so we can tear it down cleanly.
307
+ const startNode = document.createComment(`${MARKER}s`);
308
+ const endNode = document.createComment(`${MARKER}e`);
309
+
310
+ const bound = parts.map((p) => bindPart(p, frag));
311
+ const lastValues = [];
312
+ for (let i = 0; i < tr.values.length; i++) {
313
+ applyPart(bound[i], tr.values[i], undefined, tr.values, reconcileFormActions);
314
+ lastValues.push(tr.values[i]);
315
+ }
316
+ reconcileFormActions(formActions, bound, tr.values);
317
+
318
+ /** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
319
+
320
+ // Slot parts have no value-hole to drive applyPart from the loop above.
321
+ // Apply them once now that the fragment is inserted into the live
322
+ // container, so each slot can locate its host by walking parents and
323
+ // schedule the first projection through slot.js. Stamp each slot with the
324
+ // host whose TEMPLATE produced it (this container), so a FORWARDED slot
325
+ // (rendered here but nested inside a child component) routes to this host
326
+ // rather than to the child the structural walk would pick. Only an element
327
+ // container is a host; a fragment/shadow container leaves the structural
328
+ // path in place.
329
+ //
330
+ // These MUST be the symbols slot.js created. They are `Symbol(...)`, which
331
+ // is unique per call, not `Symbol.for(...)`, which looks one up in the
332
+ // global registry by string. Reaching for the registry produces a different
333
+ // symbol that no host carries, so `ownerHost` is always null, the stamp
334
+ // never lands, and a forwarded slot silently routes to the wrong host.
335
+ const ownerHost =
336
+ /** @type {any} */ (container).nodeType === 1 && /** @type {any} */ (container)[SLOT_STATE]
337
+ ? container
338
+ : null;
339
+ for (const part of bound) {
340
+ if (part.kind === 'slot') {
341
+ if (ownerHost && part.slotEl) /** @type {any} */ (part.slotEl)[SLOT_OWNER] = ownerHost;
342
+ applyPart(part, undefined, undefined, [], reconcileFormActions);
343
+ }
344
+ }
345
+
346
+ return { strings: tr.strings, bound, lastValues, startNode, endNode };
347
+ }
348
+
349
+ /**
350
+ * @param {TemplateInstance} inst
351
+ * @param {Element | DocumentFragment | ShadowRoot} container
352
+ */
353
+ function clearInstance(inst, container) {
354
+ // Dispose event listeners on event parts, unbind active refs on
355
+ // element parts, and rescue any projected children sitting inside
356
+ // slot parts so they survive teardown of a collapsing conditional
357
+ // fragment.
358
+ for (const p of inst.bound) {
359
+ if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
360
+ if (p.kind === 'element') {
361
+ // Guarded for the same reason as the sibling in `disposeInstance`, and
362
+ // the stakes are higher here: this is the container-level teardown
363
+ // `render()` runs before a template swap, so a throw skips the rest of
364
+ // this loop AND the `replaceChildren()` below, leaving the old DOM in
365
+ // place with `host[INSTANCE]` never reassigned. Since `lastTarget` is
366
+ // cleared only after the write, every later swap of that container
367
+ // then throws at the same part, permanently.
368
+ const prev = /** @type any */ (p).lastTarget;
369
+ if (prev) {
370
+ if (typeof prev === 'function') {
371
+ try { prev(undefined); } catch {}
372
+ } else if (typeof prev === 'object') {
373
+ try { prev.value = undefined; } catch {}
374
+ }
375
+ /** @type any */ (p).lastTarget = undefined;
376
+ /** @type any */ (p).__lastEl = undefined;
377
+ }
378
+ }
379
+ if (p.kind === 'slot') {
380
+ // Detach record-owned children before the teardown disposes the
381
+ // slot subtree; the record keeps the refs, so a re-created slot
382
+ // re-places the SAME nodes (children are values, #1015).
383
+ const host = findSlotHost(p.slotEl);
384
+ if (host) rescueAssignedNodes(host, p.slotEl);
385
+ }
386
+ }
387
+ /** @type any */ (container).replaceChildren();
388
+ }