@webjsdev/core 0.7.28 → 0.7.30

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.d.ts CHANGED
@@ -65,6 +65,10 @@ export type {
65
65
  } from './src/serializable.d.ts';
66
66
 
67
67
  export { html, isTemplate, MARKER } from './src/html.js';
68
+ // The `html\`\`` return type. A JSDoc typedef in html.js used across the
69
+ // component / route / testing type surfaces; re-exported so an app or the
70
+ // docs/website can annotate a value as `TemplateResult` (#772).
71
+ export type { TemplateResult } from './src/html.js';
68
72
  export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
69
73
  export { register, lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js';
70
74
  export { renderToString, renderToStream } from './src/render-server.js';
@@ -113,11 +117,33 @@ export { stringify, parse, serialize, deserialize } from './src/serialize.js';
113
117
  export { WebjsFrame } from './src/webjs-frame.js';
114
118
  export { WebjsStream, renderStream } from './src/webjs-stream.js';
115
119
 
116
- // Optimistic-mutation helper: set a signal to an expected value immediately,
117
- // run the action, roll back on a thrown error or a `{ success: false }`
118
- // ActionResult, keep the value on success. Returns the action's result.
120
+ export interface OptimisticState<State, Action> {
121
+ readonly value: State;
122
+ add(payload: Action, promise?: Promise<any> | any): () => void;
123
+ }
124
+
125
+ // Declarative Signature with custom update reducer
126
+ export function optimistic<State, Action>(
127
+ host: { requestUpdate?(): void },
128
+ options: {
129
+ source: () => State;
130
+ update: (state: State, action: Action) => State;
131
+ }
132
+ ): OptimisticState<State, Action>;
133
+
134
+ // Declarative Signature with default replace reducer (Action = State)
135
+ export function optimistic<State>(
136
+ host: { requestUpdate?(): void },
137
+ options: {
138
+ source: () => State;
139
+ }
140
+ ): OptimisticState<State, State>;
141
+
142
+
143
+ // Legacy Imperative Signature (Signal-based rollback)
119
144
  export function optimistic<T, R>(
120
145
  signal: { get(): T; set(v: T): void },
121
146
  value: T,
122
147
  action: () => Promise<R> | R,
123
148
  ): Promise<R>;
149
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.28",
3
+ "version": "0.7.30",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The template attribute-binding prefixes the renderers recognise.
3
+ *
4
+ * A binding hole whose attribute name starts with one of these is NOT a
5
+ * plain attribute: `@event` is a client event listener (dropped at SSR,
6
+ * wired only after hydration), `.prop` is a DOM property, `?bool` a
7
+ * boolean attribute. This object is the SINGLE source of truth for that
8
+ * set: both the client renderer (`render-client.js`) and the server
9
+ * renderer (`render-server.js`, two sites) read it instead of hardcoding
10
+ * the prefix characters inline.
11
+ *
12
+ * It is also the anchor for the elision drift guard. The analyser
13
+ * (`packages/server/src/component-elision.js`) classifies every prefix as
14
+ * either a client-behaviour ship signal (it drops at SSR and implies the
15
+ * component does client work, so a component using it must ship) or an
16
+ * SSR-safe round-trip (it survives into the served HTML, so it is not a
17
+ * ship signal). The guard test
18
+ * (`packages/server/test/elision/sigil-coverage.test.js`) asserts that
19
+ * classification covers EXACTLY these keys, so a new prefix cannot be
20
+ * added here without the analyser being taught which kind it is. That
21
+ * closes the one gap the prototype-introspection guard
22
+ * (`lifecycle-coverage.test.js`) cannot reach, because a sigil is syntax,
23
+ * not a prototype method or a named export.
24
+ *
25
+ * @type {Readonly<Record<string, 'event' | 'prop' | 'bool'>>}
26
+ */
27
+ export const BINDING_PREFIXES = Object.freeze({
28
+ '@': 'event',
29
+ '.': 'prop',
30
+ '?': 'bool',
31
+ });
32
+
33
+ /**
34
+ * True if `ch` is a recognised binding prefix. A single-character string is
35
+ * expected (the first char of an attribute name).
36
+ *
37
+ * @param {string} ch
38
+ * @returns {boolean}
39
+ */
40
+ export function isBindingPrefix(ch) {
41
+ return Object.prototype.hasOwnProperty.call(BINDING_PREFIXES, ch);
42
+ }
package/src/component.js CHANGED
@@ -573,7 +573,7 @@ class WebComponentBase extends Base {
573
573
  `properties via the factory instead: \`class ${name} extends WebComponent({ ` +
574
574
  `count: Number })\`. Use the \`prop()\` helper for options ` +
575
575
  `(\`prop(Number, { reflect: true })\`) and set defaults in the ` +
576
- `constructor after \`super()\`. See https://docs.webjs.com/docs/components.`,
576
+ `constructor after \`super()\`. See https://docs.webjs.dev/docs/components.`,
577
577
  );
578
578
  }
579
579
  C = Object.getPrototypeOf(C);
package/src/optimistic.js CHANGED
@@ -1,64 +1,120 @@
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) {
1
+ class OptimisticState {
2
+ constructor(host, options) {
3
+ this.host = host;
4
+ this.options = options;
5
+ this.updates = [];
6
+ this._nextId = 0;
7
+ }
8
+
9
+ get value() {
10
+ let current = this.options.source();
11
+ if (!this.options.update) {
12
+ return this.updates.length > 0
13
+ ? this.updates[this.updates.length - 1].payload
14
+ : current;
15
+ }
16
+ for (const update of this.updates) {
17
+ current = this.options.update(current, update.payload);
18
+ }
19
+ return current;
20
+ }
21
+
22
+ add(payload, promise) {
23
+ const id = `opt-${++this._nextId}`;
24
+ this.updates.push({ id, payload });
25
+ this.host?.requestUpdate?.();
26
+
27
+ const release = () => {
28
+ const idx = this.updates.findIndex(u => u.id === id);
29
+ if (idx !== -1) {
30
+ this.updates.splice(idx, 1);
31
+ this.host?.requestUpdate?.();
32
+ }
33
+ };
34
+
35
+ if (promise && typeof promise.then === 'function') {
36
+ if (typeof promise.finally === 'function') {
37
+ promise.finally(() => release()).catch(() => {});
38
+ } else {
39
+ promise.then(() => release(), () => release());
40
+ }
41
+ }
42
+
43
+ return release;
44
+ }
45
+ }
46
+
47
+ async function runLegacyOptimistic(signal, value, action) {
46
48
  const prev = signal.get();
47
49
  signal.set(value);
48
50
  let result;
49
51
  try {
50
52
  result = await action();
51
53
  } 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
54
  signal.set(prev);
55
55
  throw err;
56
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
57
  if (result && result.success === false) {
61
58
  signal.set(prev);
62
59
  }
63
60
  return result;
64
61
  }
62
+
63
+ /**
64
+ * `optimistic(host, options)`, a React 19 / Next.js-style declarative
65
+ * optimistic-state wrapper for Web Components.
66
+ *
67
+ * The optimistic-UI pattern: show the expected result of a mutation
68
+ * IMMEDIATELY (so the interface feels instant), run the real server action,
69
+ * and release the optimistic overlay when the action settles. This wrapper
70
+ * manages a queue of pending updates, computes the combined value through
71
+ * an optional reducer, and auto-releases when a passed promise resolves.
72
+ *
73
+ * import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
74
+ * import { createTodo } from '../actions/create-todo.server.js';
75
+ *
76
+ * class TodoList extends WebComponent({ todos: prop(Array) }) {
77
+ * optimisticTodos = optimistic(this, {
78
+ * source: () => this.todos,
79
+ * update: (state, title) => [...state, { id: 'tmp', title, pending: true }]
80
+ * });
81
+ *
82
+ * async handleSubmit(e) {
83
+ * const title = e.target.querySelector('input').value;
84
+ * const promise = createTodo({ title });
85
+ * this.optimisticTodos.add(title, promise);
86
+ * const result = await promise;
87
+ * if (result.success) this.todos = [...this.todos, result.data];
88
+ * }
89
+ *
90
+ * render() {
91
+ * return html`<ul>${this.optimisticTodos.value.map(t =>
92
+ * html`<li class=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}</ul>`;
93
+ * }
94
+ * }
95
+ *
96
+ * Behaviour:
97
+ * 1. `.value` reads `source()` and folds all queued updates through `update`.
98
+ * 2. `.add(payload)` pushes an update and calls `host.requestUpdate()`.
99
+ * 3. `.add(payload, promise)` auto-releases on promise settlement.
100
+ * 4. The returned `release()` fn removes the update by ID and re-renders.
101
+ * 5. Concurrent updates stack; each release removes only its own entry.
102
+ *
103
+ * Backward-compatible imperative API (signal-based rollback):
104
+ * await optimistic(signal, value, () => likePost(postId));
105
+ *
106
+ * Client-only: it calls `host.requestUpdate()` (client work), so a component
107
+ * importing it is never elided as display-only.
108
+ *
109
+ * @template State
110
+ * @template Action
111
+ * @param {{ requestUpdate?: () => void }} host A WebComponent instance.
112
+ * @param {{ source: () => State, update?: (state: State, action: Action) => State }} options
113
+ * @returns {OptimisticState<State, Action>}
114
+ */
115
+ export function optimistic(first, second, third) {
116
+ if (first && typeof first.get === 'function' && typeof first.set === 'function') {
117
+ return runLegacyOptimistic(first, second, third);
118
+ }
119
+ return new OptimisticState(first, second);
120
+ }
@@ -1,4 +1,5 @@
1
1
  import { isTemplate, MARKER } from './html.js';
2
+ import { BINDING_PREFIXES, isBindingPrefix } from './binding-prefixes.js';
2
3
  import { escapeAttr } from './escape.js';
3
4
  import { isRepeat } from './repeat.js';
4
5
  import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
@@ -307,10 +308,10 @@ function compile(tr) {
307
308
  } else if (state === 'after-eq') {
308
309
  const prefix = attrName[0];
309
310
  const name = attrName.slice(1);
310
- if (prefix === '@' || prefix === '.' || prefix === '?') {
311
+ if (isBindingPrefix(prefix)) {
311
312
  // Strip the attribute name+"=" from html and add a sentinel attr.
312
313
  html = html.slice(0, attrStart);
313
- const kind = prefix === '@' ? 'event' : prefix === '.' ? 'prop' : 'bool';
314
+ const kind = BINDING_PREFIXES[prefix];
314
315
  const sentinel = `data-${MARKER}${partIdx}`;
315
316
  html += `${sentinel}=""`;
316
317
  parts.push({ kind, path: [], name });
@@ -1,4 +1,5 @@
1
1
  import { html, isTemplate } from './html.js';
2
+ import { BINDING_PREFIXES } from './binding-prefixes.js';
2
3
  import { escapeText, escapeAttr } from './escape.js';
3
4
  import { lookup, lookupModuleUrl, allTags } from './registry.js';
4
5
  import { stylesToString, isCSS } from './css.js';
@@ -256,12 +257,13 @@ async function renderTemplate(tr, ctx) {
256
257
  } else if (state === 'after-eq') {
257
258
  const prefix = attrName[0];
258
259
  const name = attrName.slice(1);
259
- if (prefix === '@') {
260
+ const kind = BINDING_PREFIXES[prefix];
261
+ if (kind === 'event') {
260
262
  // Event listener. Client-only behaviour, drop at SSR.
261
263
  out = out.slice(0, attrStart);
262
264
  state = 'in-tag';
263
265
  attrName = '';
264
- } else if (prefix === '.') {
266
+ } else if (kind === 'prop') {
265
267
  // Property binding. Only meaningful on custom elements (which
266
268
  // have a hyphen in the tag name and a WebComponent subclass
267
269
  // that knows how to apply + strip data-webjs-prop-* on
@@ -315,7 +317,7 @@ async function renderTemplate(tr, ctx) {
315
317
  }
316
318
  state = 'in-tag';
317
319
  attrName = '';
318
- } else if (prefix === '?') {
320
+ } else if (kind === 'bool') {
319
321
  out = out.slice(0, attrStart);
320
322
  if (val) out += `${name}=""`;
321
323
  state = 'in-tag';
@@ -1422,11 +1424,12 @@ async function streamTemplate(tr, ctx, controller) {
1422
1424
  } else if (state === 'after-eq') {
1423
1425
  const prefix = attrName[0];
1424
1426
  const name = attrName.slice(1);
1425
- if (prefix === '@' || prefix === '.') {
1427
+ const kind = BINDING_PREFIXES[prefix];
1428
+ if (kind === 'event' || kind === 'prop') {
1426
1429
  buf = buf.slice(0, attrStart);
1427
1430
  state = 'in-tag';
1428
1431
  attrName = '';
1429
- } else if (prefix === '?') {
1432
+ } else if (kind === 'bool') {
1430
1433
  buf = buf.slice(0, attrStart);
1431
1434
  if (val) buf += `${name}=""`;
1432
1435
  state = 'in-tag';