@webjsdev/core 0.7.64 → 0.8.0

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 (58) 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 -22
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +6 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +6 -2
  8. package/index.d.ts +7 -3
  9. package/index.js +6 -2
  10. package/package.json +1 -1
  11. package/src/attribute-directives.d.ts +6 -0
  12. package/src/attribute-directives.js +49 -0
  13. package/src/attribute-reader.js +37 -3
  14. package/src/binding-prefixes.js +10 -41
  15. package/src/component/lifecycle.js +261 -101
  16. package/src/component/reactive.js +5 -4
  17. package/src/component.d.ts +11 -5
  18. package/src/directives.d.ts +2 -0
  19. package/src/directives.js +2 -0
  20. package/src/escape.js +12 -2
  21. package/src/form-action.js +7 -7
  22. package/src/html.d.ts +4 -0
  23. package/src/html.js +22 -1
  24. package/src/optimistic.js +3 -3
  25. package/src/registry.d.ts +5 -0
  26. package/src/registry.js +35 -4
  27. package/src/render-client/adopt-slots.js +245 -0
  28. package/src/render-client/parts.js +53 -19
  29. package/src/render-client/reconciler.js +28 -12
  30. package/src/render-client/template-compiler.js +19 -5
  31. package/src/render-client.d.ts +2 -1
  32. package/src/render-server/attrs.js +5 -4
  33. package/src/render-server/compile.js +433 -0
  34. package/src/render-server/dsd.js +11 -435
  35. package/src/render-server/errors.js +84 -0
  36. package/src/render-server/expand.js +346 -0
  37. package/src/render-server/html-scan.js +19 -358
  38. package/src/render-server/served-attrs.js +79 -0
  39. package/src/render-server/stream.js +11 -13
  40. package/src/render-server/template-renderer.js +13 -936
  41. package/src/render-server/walk.js +798 -0
  42. package/src/router-client/anchors.js +3 -1
  43. package/src/router-client/boundaries.js +15 -1
  44. package/src/router-client/constants.js +30 -0
  45. package/src/router-client/fetch-apply.js +21 -0
  46. package/src/router-client/frames.js +6 -3
  47. package/src/router-client/head-merge.js +58 -10
  48. package/src/router-client/navigator.js +4 -0
  49. package/src/router-client/owned-styles.js +372 -0
  50. package/src/router-client/prefetch.js +3 -0
  51. package/src/router-client/snapshot-cache.js +2 -12
  52. package/src/router-client/stylesheets.js +191 -0
  53. package/src/router-client/swap.js +49 -9
  54. package/src/sentinels.d.ts +2 -0
  55. package/src/sentinels.js +4 -0
  56. package/src/slot.js +1 -1
  57. package/src/webjs-config.d.ts +1 -1
  58. package/src/render-server/slots.js +0 -213
@@ -0,0 +1,346 @@
1
+ import { stylesToString, isCSS } from '../css.js';
2
+ import { unsafeHTML } from '../directives.js';
3
+ import { escapeAttr } from '../escape.js';
4
+ import { defaultSSRErrorTemplate, browserMemberHint } from './errors.js';
5
+ import {
6
+ applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
7
+ withHostMarker,
8
+ } from './attrs.js';
9
+ import { serveInstanceAttrs } from './served-attrs.js';
10
+
11
+ /**
12
+ * Custom-element expansion inside the op walk (#1646): what `injectDSD`
13
+ * used to do by rescanning the rendered string, moved here and driven by the
14
+ * walker's frames instead. `beginComponent` runs at a registered element's
15
+ * `>` (instantiate, seed and apply the source attributes, run the pre-render
16
+ * lifecycle, call `render()`, surface reflected attributes, mark a light
17
+ * host), and `finishComponent` runs at its end tag with the captured
18
+ * authored children, walking the instance's template with the light-DOM slot
19
+ * projection in scope or wrapping it in a Declarative Shadow DOM template.
20
+ * Every instance renders exactly once.
21
+ *
22
+ * The attribute plumbing stays in `attrs.js`, the light-DOM slot emission is
23
+ * `finishSlot` below (what `substituteSlotsInRender` emitted per slot), and
24
+ * `finishSuspense` is the `<webjs-suspense>` boundary (#471).
25
+ */
26
+
27
+ // Stops at the first non-whitespace character; `trim()` copied the whole
28
+ // rendered template per component to answer the same question.
29
+ const NON_BLANK = /\S/;
30
+
31
+ /**
32
+ * Instantiate and pre-render a registered custom element at its start tag.
33
+ * A throw anywhere here is caught into the frame (#469): the walker still
34
+ * consumes the authored children, and `finishComponent` emits the error state
35
+ * in the element's place while siblings render normally.
36
+ *
37
+ * @param {import('./walk.js').Walker} w
38
+ * @param {string} tag
39
+ * @param {any} Cls
40
+ * @param {string} opening the opening tag as written, `<tag attrs>`
41
+ * @param {string} attrs the attribute text between the tag name and `>`
42
+ * @returns {any} the frame; `pending` is set when `render()` returned a
43
+ * promise, and the caller awaits `settleRender` before using it
44
+ */
45
+ export function beginComponent(w, tag, Cls, opening, attrs) {
46
+ if (w.ctx && w.ctx.usedComponents) w.ctx.usedComponents.add(tag);
47
+ const isShadow = Cls.shadow === true;
48
+ const frame = {
49
+ kind: 'component', tag, Cls, isShadow, instance: null, opening, tpl: undefined,
50
+ pending: /** @type {Promise<unknown> | null} */ (null), presentAttrNames: /** @type {Set<string> | null} */ (null),
51
+ error: /** @type {unknown} */ (null), outer: '',
52
+ // Light-DOM children partition: element depth, the open top-level
53
+ // segment, and the named-slot segments seen so far.
54
+ partition: !isShadow, depth: 0, seg: null, pendingSeg: null,
55
+ /** @type {{ start: number, end: number, slot: string }[] | null} */ segments: null,
56
+ closeText: '',
57
+ };
58
+ try {
59
+ const instance = new Cls();
60
+ frame.instance = instance;
61
+ // Thread the ancestor chain (the enclosing custom-element instances) so
62
+ // the server element shim's closest() can resolve a parent at SSR. Set
63
+ // before performServerUpdate so a willUpdate() that reads a parent via
64
+ // closest() sees the chain.
65
+ instance.__ssrTag = tag;
66
+ instance.__ssrAncestors = w.ancestors.slice();
67
+ const attrMap = attrs ? parseAttrs(attrs) : {};
68
+ // Names already in the source opening tag (the data-webjs-prop-* bindings
69
+ // included, which the consume step below strips from attrMap but which
70
+ // stay in the emitted text). Reflected/added attributes are appended only
71
+ // when their name is NOT already here, so output stays byte-identical
72
+ // when nothing reflects.
73
+ const presentAttrNames = new Set();
74
+ for (const n in attrMap) presentAttrNames.add(n.toLowerCase());
75
+ // Decode `data-webjs-prop-*` first (rich-typed values emitted for
76
+ // `.prop=${val}` bindings in the parent template), then coerce the
77
+ // ordinary string attributes by declared type. Property bindings take
78
+ // priority on a name collision because they preserve the JS reference.
79
+ const propValues = consumePropAttrs(attrMap);
80
+ seedServerAttrs(instance, attrMap);
81
+ applyAttrsToInstance(instance, attrMap, Cls);
82
+ for (const k in propValues) instance[k] = propValues[k];
83
+ // The pre-render lifecycle (willUpdate, controllers' hostUpdate, then
84
+ // reflect:true reflection), matching lit's update cycle at SSR. Bare
85
+ // Base-extending kit components have no lifecycle, so it is guarded.
86
+ if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
87
+ const tpl = instance.render ? instance.render() : '';
88
+ frame.presentAttrNames = presentAttrNames;
89
+ if (tpl && typeof tpl.then === 'function') {
90
+ // An async render() is awaited by the walker through `settleRender`.
91
+ frame.pending = tpl;
92
+ return frame;
93
+ }
94
+ frame.tpl = tpl;
95
+ afterRender(frame);
96
+ } catch (e) {
97
+ frame.error = e;
98
+ }
99
+ return frame;
100
+ }
101
+
102
+ /**
103
+ * Finish `beginComponent` for an async `render()`.
104
+ * @param {any} frame
105
+ */
106
+ export async function settleRender(frame) {
107
+ try {
108
+ frame.tpl = await frame.pending;
109
+ afterRender(frame);
110
+ } catch (e) {
111
+ frame.error = e;
112
+ }
113
+ frame.pending = null;
114
+ }
115
+
116
+ function afterRender(frame) {
117
+ // Serve the attributes as the component left them up to and including
118
+ // render(): one it added is appended, one it changed carries its new value,
119
+ // and one it removed is dropped, as lit-ssr's renderAttributes() does (see
120
+ // served-attrs.js). Reading after render() captures all.
121
+ frame.opening = serveInstanceAttrs(frame.opening, frame.instance, frame.presentAttrNames);
122
+ // Mark LIGHT-DOM hosts for the `@layer webjs-host` display:block default.
123
+ // Shadow hosts are NOT marked: a document-level rule on the host beats the
124
+ // shadow tree's own `:host { display }`.
125
+ if (!frame.isShadow) frame.opening = withHostMarker(frame.opening);
126
+ }
127
+
128
+ /**
129
+ * Emit a finished component frame.
130
+ *
131
+ * @param {import('./walk.js').Walker} w
132
+ * @param {any} frame
133
+ * @param {string} children the captured authored children, expanded
134
+ * @returns {Promise<string>}
135
+ */
136
+ export async function finishComponent(w, frame, children) {
137
+ const { tag, Cls, isShadow } = frame;
138
+ if (frame.error === null) {
139
+ try {
140
+ const inner = await renderOwnTemplate(w, frame, children);
141
+ if (isShadow) {
142
+ // Native <slot> stays as-is in the DSD template: the browser projects
143
+ // the host's light children natively. The authored children stay in
144
+ // place after the template, and the source end tag is kept.
145
+ return `${frame.opening}<template shadowrootmode="open">${styleString(Cls)}${inner}</template>${children}${frame.closeText}`;
146
+ }
147
+ if (!NON_BLANK.test(inner)) {
148
+ // An empty rendered template (a decorator component with no render(),
149
+ // or a render() returning an empty template): the host is a
150
+ // transparent wrapper and the authored children stay in place next to
151
+ // the hydration marker.
152
+ return `${frame.opening}<!--webjs-hydrate-->${children}${frame.closeText}`;
153
+ }
154
+ return `${frame.opening}<!--webjs-hydrate-->${inner}</${tag}>`;
155
+ } catch (e) {
156
+ frame.error = e;
157
+ }
158
+ }
159
+ return errorState(w, frame);
160
+ }
161
+
162
+ /**
163
+ * Walk the instance's own template. For a light-DOM component the slot
164
+ * projection is in scope: the authored children partitioned by their
165
+ * top-level `slot=""` attribute (from the segments the walk recorded, no
166
+ * rescan), first-wins per name, owner stamped as this tag so a FORWARDED slot
167
+ * routes to the host that rendered it (#1023).
168
+ */
169
+ async function renderOwnTemplate(w, frame, children) {
170
+ const owner = w.owner;
171
+ w.owner = frame.isShadow ? null : { tag: frame.tag, partition: partitionChildren(children, frame.segments), consumed: null };
172
+ try {
173
+ return await w.capture(() => w.value(frame.tpl));
174
+ } finally {
175
+ w.owner = owner;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The partition `partitionAuthoredBySlot` built by rescanning, built here from
181
+ * the recorded segments: text, comments and unslotted elements route to the
182
+ * default slot (null) in source order, each named segment to its name.
183
+ * @param {string} children
184
+ * @param {{ start: number, end: number, slot: string }[]} segments
185
+ * @returns {Map<string|null, string>}
186
+ */
187
+ function partitionChildren(children, segments) {
188
+ /** @type {Map<string|null, string>} */
189
+ const groups = new Map();
190
+ if (!segments) {
191
+ if (children.length > 0) groups.set(null, children);
192
+ return groups;
193
+ }
194
+ let def = '';
195
+ let cursor = 0;
196
+ for (const seg of segments) {
197
+ def += children.slice(cursor, seg.start);
198
+ const existing = groups.get(seg.slot);
199
+ groups.set(seg.slot, existing === undefined ? children.slice(seg.start, seg.end) : existing + children.slice(seg.start, seg.end));
200
+ cursor = seg.end;
201
+ }
202
+ def += children.slice(cursor);
203
+ if (def.length > 0) groups.set(null, def);
204
+ return groups;
205
+ }
206
+
207
+ function styleString(Cls) {
208
+ const rawStyles = Cls.styles;
209
+ const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
210
+ return stylesToString(styleList);
211
+ }
212
+
213
+ /**
214
+ * Per-component error isolation (#469). The element renders a
215
+ * component-scoped error state in its place (renderError() customises it;
216
+ * the default surfaces the message in dev and renders empty in prod, #483)
217
+ * plus a hydration marker so the client boundary can take over. A shadow
218
+ * component's error state rides a DSD template too, matching its success
219
+ * path.
220
+ */
221
+ async function errorState(w, frame) {
222
+ const { tag, Cls, instance, isShadow } = frame;
223
+ const e = frame.error;
224
+ const hint = browserMemberHint(e);
225
+ if (hint) {
226
+ console.error(
227
+ `[webjs] SSR failed for <${tag}>: ${hint} It was touched in the component's constructor or render(), which run during SSR. Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes / props).`,
228
+ e,
229
+ );
230
+ } else {
231
+ console.error(`[webjs] SSR failed for <${tag}>:`, e);
232
+ }
233
+ const err = e instanceof Error ? e : new Error(String(e));
234
+ let errorInner = '';
235
+ try {
236
+ let errTpl;
237
+ if (instance && typeof instance.renderError === 'function') errTpl = instance.renderError(err);
238
+ if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, w.dev);
239
+ errorInner = await w.capture(() => w.value(errTpl));
240
+ } catch (renderErrorThrew) {
241
+ console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
242
+ errorInner = '';
243
+ }
244
+ let opening = frame.opening;
245
+ if (!isShadow) opening = withHostMarker(opening);
246
+ if (isShadow) return `${opening}<template shadowrootmode="open">${styleString(Cls)}${errorInner}</template>`;
247
+ return `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Slots
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * The `slot` attribute value of an element's attribute text, or null when
256
+ * absent. `slot=""` and the reserved alias `default` both mean the default
257
+ * slot (#1015), matching the client record.
258
+ *
259
+ * The attributes are read one by one, the way the HTML tokenizer reads them,
260
+ * rather than by searching for `slot=`. A search has two false matches: the
261
+ * tail of another name (`data-slot="button"`, which every shadcn-shaped part
262
+ * carries, since `\b` sits between the hyphen and the `s`) and text inside a
263
+ * quoted value (`title="a slot=b"`). Either one routed the child to a named
264
+ * slot the template does not have, and the server dropped it (#1666).
265
+ * @param {string} attrsRaw
266
+ * @returns {string | null}
267
+ */
268
+ export function extractSlotAttr(attrsRaw) {
269
+ const found = findAttr(attrsRaw, 'slot');
270
+ if (!found) return null;
271
+ return found.value === '' || found.value === 'default' ? null : found.value;
272
+ }
273
+
274
+ /**
275
+ * Find one attribute by name in an element's attribute text, reading the
276
+ * attributes in order as the HTML tokenizer does, so a name that merely ENDS
277
+ * in `attrName` or a quoted value that mentions it never matches. Returns
278
+ * the value (empty for a bare attribute) and the span of the whole attribute.
279
+ * @param {string} attrsRaw
280
+ * @param {string} attrName lowercase
281
+ * @returns {{ value: string, start: number, end: number } | null}
282
+ */
283
+ function findAttr(attrsRaw, attrName) {
284
+ const attr = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
285
+ let m;
286
+ while ((m = attr.exec(attrsRaw))) {
287
+ if (m[1].toLowerCase() !== attrName) continue;
288
+ return { value: m[2] ?? m[3] ?? m[4] ?? '', start: m.index, end: m.index + m[0].length };
289
+ }
290
+ return null;
291
+ }
292
+
293
+ /**
294
+ * Emit one `<slot>` of a light-DOM component's template as the framework
295
+ * slot: `data-webjs-light`, `data-projection="actual|fallback"`, the owner
296
+ * stamp, the name, and the projected children or the authored fallback.
297
+ * First-wins per name; a later same-named slot falls back regardless.
298
+ * @param {any} frame
299
+ * @param {string} fallback the slot's own captured children
300
+ * @returns {string}
301
+ */
302
+ export function finishSlot(frame, fallback) {
303
+ const { attrsRaw, owner } = frame;
304
+ const ownerAttr = owner.tag ? ` data-wj-slot-owner="${escapeAttr(owner.tag)}"` : '';
305
+ let name = null;
306
+ let otherAttrs = '';
307
+ if (NON_BLANK.test(attrsRaw)) {
308
+ const found = findAttr(attrsRaw, 'name');
309
+ name = found ? found.value : null;
310
+ otherAttrs = (found ? attrsRaw.slice(0, found.start) + attrsRaw.slice(found.end) : attrsRaw).trim();
311
+ }
312
+ const slotKey = name === 'default' || name === '' ? null : name;
313
+ const projected = owner.partition.get(slotKey);
314
+ const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
315
+ const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
316
+ if (projected !== undefined && !(owner.consumed && owner.consumed.has(slotKey))) {
317
+ (owner.consumed ??= new Set()).add(slotKey);
318
+ return `<slot data-webjs-light data-projection="actual"${ownerAttr}${nameAttr}${extraAttrs}>${projected}</slot>`;
319
+ }
320
+ return `<slot data-webjs-light data-projection="fallback"${ownerAttr}${nameAttr}${extraAttrs}>${fallback}</slot>`;
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // <webjs-suspense>
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /**
328
+ * A `<webjs-suspense>` boundary (#471). Streaming (a ctx is present): the
329
+ * placeholder carries the rendered `.fallback` and the boundary id, and the
330
+ * children, captured with their components unexpanded, are pushed to
331
+ * `ctx.pending` as raw markup for `streamSuspenseBoundaries` to resolve
332
+ * after the main flush (so every boundary's data fetches run concurrently).
333
+ * Blocking: the children rendered inline, the fallback dropped, the wrapper
334
+ * kept as an inert element.
335
+ * @param {import('./walk.js').Walker} w
336
+ * @param {any} frame
337
+ * @param {string} children
338
+ * @returns {Promise<string>}
339
+ */
340
+ export async function finishSuspense(w, frame, children) {
341
+ if (w.ctx && frame.id) {
342
+ w.ctx.pending.push({ id: frame.id, promise: Promise.resolve(unsafeHTML(children)) });
343
+ return `<webjs-suspense id="${frame.id}">${frame.fallback}</webjs-suspense>`;
344
+ }
345
+ return `<webjs-suspense>${children}</webjs-suspense>`;
346
+ }