@webjsdev/core 0.7.9 → 0.7.11

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/index-browser.js CHANGED
@@ -44,10 +44,14 @@ export {
44
44
  } from './src/serialize.js';
45
45
  export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
46
46
  export { WebjsFrame } from './src/webjs-frame.js';
47
+ export { WebjsStream, renderStream } from './src/webjs-stream.js';
47
48
 
48
49
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
49
50
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
50
51
 
52
+ // Optimistic-mutation helper (thin signal wrapper, rolls back on failure)
53
+ export { optimistic } from './src/optimistic.js';
54
+
51
55
  // Directives, also available via '@webjsdev/core/directives'. The full
52
56
  // lit-html-parity set is re-exported here so the dist browser bundle (which
53
57
  // the `@webjsdev/core/directives` subpath collapses onto in dist mode) carries
package/index.d.ts CHANGED
@@ -77,3 +77,12 @@ export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void
77
77
  export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
78
78
  export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
79
79
  export { Task, TaskStatus } from './src/task.js';
80
+
81
+ // Optimistic-mutation helper: set a signal to an expected value immediately,
82
+ // run the action, roll back on a thrown error or a `{ success: false }`
83
+ // ActionResult, keep the value on success. Returns the action's result.
84
+ export function optimistic<T, R>(
85
+ signal: { get(): T; set(v: T): void },
86
+ value: T,
87
+ action: () => Promise<R> | R,
88
+ ): Promise<R>;
package/index.js CHANGED
@@ -26,10 +26,14 @@ export {
26
26
  } from './src/serialize.js';
27
27
  export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
28
28
  export { WebjsFrame } from './src/webjs-frame.js';
29
+ export { WebjsStream, renderStream } from './src/webjs-stream.js';
29
30
 
30
31
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
31
32
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
32
33
 
34
+ // Optimistic-mutation helper (thin signal wrapper, rolls back on failure)
35
+ export { optimistic } from './src/optimistic.js';
36
+
33
37
  // Directives, also available via '@webjsdev/core/directives'. The full
34
38
  // lit-html-parity set is re-exported so the bare specifier exposes the same
35
39
  // directive surface in Node as the browser bundle does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -105,3 +105,59 @@ function onIntersect(entries) {
105
105
  observer?.unobserve(entry.target);
106
106
  }
107
107
  }
108
+
109
+ /* ====================================================================
110
+ * Per-element viewport observation (shared with <webjs-frame loading="lazy">)
111
+ * ==================================================================== */
112
+
113
+ /**
114
+ * A separate IntersectionObserver keyed by ELEMENT (not by tag) so a single
115
+ * element can fire a one-shot callback when it scrolls into view. Reuses the
116
+ * same `rootMargin: '200px'` warm-up budget as the tag-based lazy-component
117
+ * observer above, so a `<webjs-frame loading="lazy">` self-loads with the same
118
+ * timing as a `static lazy = true` component module.
119
+ *
120
+ * @type {IntersectionObserver | null}
121
+ */
122
+ let elementObserver = null;
123
+
124
+ /** @type {WeakMap<Element, () => void>} element → its one-shot callback */
125
+ const elementCallbacks = new WeakMap();
126
+
127
+ /** @param {IntersectionObserverEntry[]} entries */
128
+ function onElementIntersect(entries) {
129
+ for (const entry of entries) {
130
+ if (!entry.isIntersecting) continue;
131
+ const cb = elementCallbacks.get(entry.target);
132
+ elementCallbacks.delete(entry.target);
133
+ elementObserver?.unobserve(entry.target);
134
+ if (cb) cb();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Fire `callback` once when `el` first enters the viewport (within the
140
+ * shared 200px margin). When IntersectionObserver is unavailable (very old
141
+ * browser, or a non-DOM test env) the callback fires immediately, so a lazy
142
+ * frame still self-loads rather than staying blank. Returns a teardown that
143
+ * stops observing (the frame calls it on disconnect / a `src` change).
144
+ *
145
+ * @param {Element} el
146
+ * @param {() => void} callback
147
+ * @returns {() => void} Teardown that unobserves `el`.
148
+ */
149
+ export function observeViewportOnce(el, callback) {
150
+ if (typeof IntersectionObserver === 'undefined') {
151
+ callback();
152
+ return () => {};
153
+ }
154
+ if (!elementObserver) {
155
+ elementObserver = new IntersectionObserver(onElementIntersect, { rootMargin: '200px' });
156
+ }
157
+ elementCallbacks.set(el, callback);
158
+ elementObserver.observe(el);
159
+ return () => {
160
+ elementCallbacks.delete(el);
161
+ elementObserver?.unobserve(el);
162
+ };
163
+ }
package/src/metadata.d.ts CHANGED
@@ -152,6 +152,14 @@ export type AppleWebAppMetadata =
152
152
  */
153
153
  export type JsonLd = Record<string, unknown>;
154
154
 
155
+ /**
156
+ * A `metadata.preconnect` / `metadata.dnsPrefetch` hint. A bare URL string,
157
+ * or an object form. `crossorigin` (preconnect only) emits the `crossorigin`
158
+ * attribute; `true` / `''` -> a bare `crossorigin`, a string -> its value
159
+ * (e.g. `'anonymous'`, `'use-credentials'`).
160
+ */
161
+ export type PreconnectHint = string | { url: string; crossorigin?: string | boolean };
162
+
155
163
  /** A `metadata.preload` link descriptor (emitted as `<link rel="preload">`). */
156
164
  export interface PreloadDescriptor {
157
165
  href: MetadataUrl;
@@ -247,6 +255,23 @@ export interface Metadata {
247
255
  /** `<link rel="preload">` hints (fonts, images, etc.). */
248
256
  preload?: PreloadDescriptor[];
249
257
 
258
+ /**
259
+ * `<link rel="preconnect">` hints: warm DNS + TLS + TCP to a cross-origin
260
+ * the page is about to talk to (an API host, a font / image CDN). Each
261
+ * entry is a URL string or `{ url, crossorigin? }` (a font CDN needs
262
+ * `crossorigin`). A single value or an array. webjs ALSO auto-emits one
263
+ * preconnect to the resolved vendor CDN origin for an unpinned app (deduped
264
+ * against an author-declared one).
265
+ */
266
+ preconnect?: PreconnectHint | PreconnectHint[];
267
+
268
+ /**
269
+ * `<link rel="dns-prefetch">` hints: resolve a cross-origin's DNS ahead of
270
+ * use (a lighter-weight precursor to `preconnect`). A URL string,
271
+ * `{ url }`, or an array.
272
+ */
273
+ dnsPrefetch?: PreconnectHint | PreconnectHint[];
274
+
250
275
  /**
251
276
  * JSON-LD structured data (schema.org), emitted as one or more
252
277
  * `<script type="application/ld+json">` blocks in `<head>`. A single
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `optimistic(signal, value, action)`, a thin optimistic-mutation helper.
3
+ *
4
+ * The optimistic-UI pattern: show the expected result of a mutation
5
+ * IMMEDIATELY (so the interface feels instant), run the real server action,
6
+ * and roll the optimistic value back if the action fails. This wrapper does
7
+ * exactly that and nothing more, no state machine, no extra reactivity, just
8
+ * the existing signal primitive plus a try / envelope check.
9
+ *
10
+ * import { signal, optimistic } from '@webjsdev/core';
11
+ * import { likePost } from '../actions/like-post.server.js';
12
+ *
13
+ * const liked = signal(false);
14
+ * // In an @click handler:
15
+ * await optimistic(liked, true, () => likePost(postId));
16
+ * // `liked` flips to true instantly; if likePost throws or returns
17
+ * // { success: false }, it rolls back to its prior value.
18
+ *
19
+ * Behaviour:
20
+ * 1. Capture `prev = signal.get()`.
21
+ * 2. `signal.set(value)` so the UI updates before the round-trip.
22
+ * 3. `await action()`.
23
+ * 4a. The action THROWS -> `signal.set(prev)` (rollback) + re-throw.
24
+ * 4b. The action returns an ActionResult FAILURE envelope
25
+ * (`result && result.success === false`) -> `signal.set(prev)`
26
+ * (rollback) + return the result (the caller reads its
27
+ * `error` / `fieldErrors`).
28
+ * 4c. SUCCESS (anything else) -> keep the optimistic value + return the
29
+ * result. The caller can reconcile to the authoritative value from the
30
+ * returned result if it wants to (e.g. `signal.set(result.data.count)`).
31
+ *
32
+ * It rolls back on a thrown error OR a `{ success: false }` ActionResult, and
33
+ * never rolls back on success. Client-only: it mutates a signal (client work),
34
+ * so a component importing it is never elided as display-only.
35
+ *
36
+ * @template T
37
+ * @param {{ get: () => T, set: (v: T) => void }} signal A webjs signal.
38
+ * @param {T} value The optimistic value to show immediately.
39
+ * @param {() => Promise<any> | any} action The server-action call (or any
40
+ * thunk returning a Promise / value).
41
+ * @returns {Promise<any>} The action's result. On a `{ success: false }`
42
+ * envelope the rolled-back result is returned (not thrown); a thrown action
43
+ * re-throws after rollback.
44
+ */
45
+ export async function optimistic(signal, value, action) {
46
+ const prev = signal.get();
47
+ signal.set(value);
48
+ let result;
49
+ try {
50
+ result = await action();
51
+ } catch (err) {
52
+ // The action rejected: roll the optimistic value back and let the caller
53
+ // handle the error (re-throw, do not swallow).
54
+ signal.set(prev);
55
+ throw err;
56
+ }
57
+ // ActionResult FAILURE envelope: a `{ success: false }` is a handled
58
+ // failure, not a throw. Roll back and hand the result back so the caller
59
+ // can read its `error` / `fieldErrors`.
60
+ if (result && result.success === false) {
61
+ signal.set(prev);
62
+ }
63
+ return result;
64
+ }
@@ -37,8 +37,12 @@ import {
37
37
  * - Event listeners are attached once and retargeted when the handler
38
38
  * reference changes (swap-in-place via a dispatch closure, so `addEventListener`
39
39
  * isn't churned every render).
40
- * - This is a conservative implementation; no keyed list diffing yet: array
41
- * changes rebuild the whole text part.
40
+ * - A plain `.map()` array reconciles POSITIONALLY (non-keyed), matching
41
+ * lit-html: each index updates its item instance in place when the
42
+ * template shape is unchanged, so DOM node identity (and the focus,
43
+ * selection, scroll, and in-progress native drag it carries) survives an
44
+ * item-level update. See `reconcileArray`. Keyed reordering still needs
45
+ * the `repeat()` directive.
42
46
  */
43
47
 
44
48
  /** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[] }>} */
@@ -911,12 +915,35 @@ function applyChildInner(part, value) {
911
915
  return;
912
916
  }
913
917
 
918
+ // Plain array (a `.map()` / list interpolation): positional, non-keyed
919
+ // reconciliation. Update each position's instance IN PLACE when its
920
+ // template shape is unchanged, so DOM node identity survives an item
921
+ // update (focus, selection, scroll, and an in-progress native drag all
922
+ // survive). Mirrors lit-html's non-keyed array child part. Without this,
923
+ // flipping one item's binding rebuilt the WHOLE list and detached every
924
+ // node, which cancels a native drag mid-gesture. Use `repeat()` for keyed
925
+ // reordering.
926
+ if (Array.isArray(value)) {
927
+ if (part.child && /** @type any */ (part.child).kind === 'array') {
928
+ reconcileArray(part, value);
929
+ return;
930
+ }
931
+ teardownChild(part);
932
+ const arrState = { kind: 'array', items: [] };
933
+ part.child = arrState;
934
+ applyArrayFresh(marker, /** @type any */ (arrState), value);
935
+ return;
936
+ }
937
+
914
938
  // Remove previously rendered nodes between marker and its next sibling we own.
915
939
  if (part.child) {
916
940
  const c = /** @type any */ (part.child);
917
941
  if (c.kind === 'repeat') {
918
942
  teardownRepeat(c);
919
943
  part.child = undefined;
944
+ } else if (c.kind === 'array') {
945
+ teardownArray(/** @type any */ (c));
946
+ part.child = undefined;
920
947
  } else if (c.kind === 'async-stream') {
921
948
  teardownAsyncStream(c);
922
949
  part.child = undefined;
@@ -966,29 +993,6 @@ function applyChildInner(part, value) {
966
993
  return;
967
994
  }
968
995
 
969
- if (Array.isArray(value)) {
970
- const list = [];
971
- for (const v of value) {
972
- if (isTemplate(v)) {
973
- // Create an inline instance. No keyed reconciliation yet.
974
- const tr = /** @type any */ (v);
975
- const { templateEl, parts } = compile(tr);
976
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
977
- const bound = parts.map((p) => bindPart(p, frag));
978
- for (let i = 0; i < tr.values.length; i++) {
979
- applyPart(bound[i], tr.values[i], undefined, tr.values);
980
- }
981
- list.push(...frag.childNodes);
982
- } else if (v != null && v !== false && v !== true) {
983
- list.push(document.createTextNode(String(v)));
984
- }
985
- }
986
- const frag = nodesToFrag(list);
987
- marker.parentNode?.insertBefore(frag, marker);
988
- part.child = list;
989
- return;
990
- }
991
-
992
996
  const node = document.createTextNode(String(value));
993
997
  marker.parentNode?.insertBefore(node, marker);
994
998
  part.child = [node];
@@ -1150,6 +1154,151 @@ function teardownRepeat(state) {
1150
1154
  state.map.clear();
1151
1155
  }
1152
1156
 
1157
+ /* ================================================================
1158
+ * Plain array (.map) support: positional, non-keyed reconciliation
1159
+ * ================================================================ */
1160
+
1161
+ /**
1162
+ * One rendered slot of a plain array. A `tpl` carries a detached template
1163
+ * instance (bookended by its own markers), a `text` carries a single text
1164
+ * node, and an `empty` slot (a nullish / boolean element) renders nothing
1165
+ * but still holds the position so index-based reconciliation stays aligned.
1166
+ * @typedef {{ type: 'tpl', inst: TemplateInstance } | { type: 'text', node: Text } | { type: 'empty' }} ArrayItem
1167
+ */
1168
+
1169
+ /** @param {ArrayItem} item @returns {ChildNode | null} */
1170
+ function arrayItemFirstNode(item) {
1171
+ if (item.type === 'tpl') return item.inst.startNode;
1172
+ if (item.type === 'text') return item.node;
1173
+ return null;
1174
+ }
1175
+
1176
+ /** @param {ArrayItem} item */
1177
+ function removeArrayItem(item) {
1178
+ if (item.type === 'tpl') {
1179
+ disposeInstance(item.inst);
1180
+ removeBetween(item.inst.startNode, item.inst.endNode);
1181
+ } else if (item.type === 'text') {
1182
+ item.node.parentNode?.removeChild(item.node);
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Build the slot for one array element, plus the fragment to insert (null
1188
+ * for an empty slot). A TemplateResult becomes a detached instance, a
1189
+ * primitive becomes a text node, and nullish / boolean renders nothing.
1190
+ * @param {unknown} v
1191
+ * @returns {{ item: ArrayItem, frag: Node | null }}
1192
+ */
1193
+ function buildArrayItem(v) {
1194
+ if (isTemplate(v)) {
1195
+ const { inst, frag } = buildDetached(/** @type any */ (v));
1196
+ return { item: { type: 'tpl', inst }, frag };
1197
+ }
1198
+ if (v != null && v !== false && v !== true) {
1199
+ const node = document.createTextNode(String(v));
1200
+ return { item: { type: 'text', node }, frag: node };
1201
+ }
1202
+ return { item: { type: 'empty' }, frag: null };
1203
+ }
1204
+
1205
+ /**
1206
+ * Initial render of a plain array: insert every slot's nodes before the marker.
1207
+ * @param {Comment} marker
1208
+ * @param {{ kind: 'array', items: ArrayItem[] }} state
1209
+ * @param {unknown[]} value
1210
+ */
1211
+ function applyArrayFresh(marker, state, value) {
1212
+ const parent = marker.parentNode;
1213
+ if (!parent) return;
1214
+ const bulk = document.createDocumentFragment();
1215
+ /** @type {ArrayItem[]} */
1216
+ const items = [];
1217
+ for (const v of value) {
1218
+ const { item, frag } = buildArrayItem(v);
1219
+ if (frag) bulk.appendChild(frag);
1220
+ items.push(item);
1221
+ }
1222
+ parent.insertBefore(bulk, marker);
1223
+ state.items = items;
1224
+ }
1225
+
1226
+ /**
1227
+ * Positional (non-keyed) reconciliation. For each index, update the slot
1228
+ * in place when its shape is unchanged (preserving DOM node identity),
1229
+ * otherwise replace it; grow or shrink at the tail. There is no key
1230
+ * matching, so a reorder reduces to a series of in-place value updates;
1231
+ * reach for `repeat()` when element identity must follow a moved key.
1232
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1233
+ * @param {unknown[]} value
1234
+ */
1235
+ function reconcileArray(part, value) {
1236
+ const marker = part.marker;
1237
+ const parent = marker.parentNode;
1238
+ if (!parent) return;
1239
+ const state = /** @type {{ kind: 'array', items: ArrayItem[] }} */ (part.child);
1240
+ const old = state.items;
1241
+ /** @type {ArrayItem[]} */
1242
+ const next = [];
1243
+
1244
+ for (let i = 0; i < value.length; i++) {
1245
+ const v = value[i];
1246
+ const o = old[i];
1247
+ if (isTemplate(v)) {
1248
+ const tr = /** @type any */ (v);
1249
+ if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
1250
+ updateInstance(o.inst, tr.values);
1251
+ next.push(o);
1252
+ continue;
1253
+ }
1254
+ } else if (v != null && v !== false && v !== true) {
1255
+ if (o && o.type === 'text') {
1256
+ const str = String(v);
1257
+ if (o.node.data !== str) o.node.data = str;
1258
+ next.push(o);
1259
+ continue;
1260
+ }
1261
+ } else {
1262
+ // Empty slot: drop any prior nodes that occupied this position.
1263
+ if (o) removeArrayItem(o);
1264
+ next.push({ type: 'empty' });
1265
+ continue;
1266
+ }
1267
+ // Shape changed, or the array grew past the old length. Build fresh,
1268
+ // insert at this position (before the current / next still-attached
1269
+ // old node, else the marker), then drop the old slot it replaced.
1270
+ const { item, frag } = buildArrayItem(v);
1271
+ if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
1272
+ if (o) removeArrayItem(o);
1273
+ next.push(item);
1274
+ }
1275
+
1276
+ // Shrink: remove slots beyond the new length.
1277
+ for (let i = value.length; i < old.length; i++) removeArrayItem(old[i]);
1278
+ state.items = next;
1279
+ }
1280
+
1281
+ /**
1282
+ * The node a freshly-built slot at index `i` inserts before: the first
1283
+ * node of the current or next still-attached old slot, else the part
1284
+ * marker (a tail append).
1285
+ * @param {ArrayItem[]} old @param {number} i @param {Comment} marker
1286
+ * @returns {ChildNode}
1287
+ */
1288
+ function nextArrayAnchor(old, i, marker) {
1289
+ for (let j = i; j < old.length; j++) {
1290
+ const f = arrayItemFirstNode(old[j]);
1291
+ if (f && f.parentNode) return f;
1292
+ }
1293
+ return marker;
1294
+ }
1295
+
1296
+ /** @param {{ kind: 'array', items: ArrayItem[] }} state */
1297
+ function teardownArray(state) {
1298
+ for (const it of state.items) removeArrayItem(it);
1299
+ state.items = [];
1300
+ }
1301
+
1153
1302
  /**
1154
1303
  * Collect [start .. end] (inclusive) and insert immediately before `anchor`.
1155
1304
  * Browsers treat insertBefore of an already-connected node as a move and
@@ -1208,6 +1357,8 @@ function teardownChild(part) {
1208
1357
  const c = /** @type any */ (part.child);
1209
1358
  if (c.kind === 'repeat') {
1210
1359
  teardownRepeat(c);
1360
+ } else if (c.kind === 'array') {
1361
+ teardownArray(c);
1211
1362
  } else if (c.kind === 'async-stream') {
1212
1363
  teardownAsyncStream(c);
1213
1364
  } else if ('strings' in c) {