@webjsdev/core 0.7.27 → 0.7.29
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 +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +17 -17
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +4 -4
- package/dist/webjs-core.js.map +3 -3
- package/index.d.ts +29 -3
- package/package.json +1 -1
- package/src/binding-prefixes.js +42 -0
- package/src/html.js +9 -3
- package/src/optimistic.js +106 -50
- package/src/render-client.js +3 -2
- package/src/render-server.js +8 -5
- package/src/router-client.js +8 -0
- package/src/slot.js +1 -1
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
@@ -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/html.js
CHANGED
|
@@ -24,7 +24,13 @@ export function isTemplate(x) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Marker used in the DOM to find hydration points.
|
|
28
|
-
*
|
|
27
|
+
* Marker used in the DOM to find hydration points. It is interpolated into
|
|
28
|
+
* BOTH comment markers (`<!--${MARKER}0-->`) and part-sentinel ATTRIBUTE names
|
|
29
|
+
* (`data-${MARKER}0`), so it MUST contain only characters valid in an XML
|
|
30
|
+
* qualified name. A `$` (the original value `'w$'`) is NOT valid in an
|
|
31
|
+
* attribute name: most engines tolerate it, but iOS WebKit's `setAttribute`
|
|
32
|
+
* enforces the spec and throws `InvalidCharacterError: Invalid qualified name`,
|
|
33
|
+
* which crashed `createInstance` for every slot template on iOS (#730). Keep it
|
|
34
|
+
* to `[a-z][a-z0-9-]*`. Enforced by `test/rendering/marker-valid-attr-name.test.js`.
|
|
29
35
|
*/
|
|
30
|
-
export const MARKER = '
|
|
36
|
+
export const MARKER = 'wjm-';
|
package/src/optimistic.js
CHANGED
|
@@ -1,64 +1,120 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
package/src/render-client.js
CHANGED
|
@@ -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
|
|
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
|
|
314
|
+
const kind = BINDING_PREFIXES[prefix];
|
|
314
315
|
const sentinel = `data-${MARKER}${partIdx}`;
|
|
315
316
|
html += `${sentinel}=""`;
|
|
316
317
|
parts.push({ kind, path: [], name });
|
package/src/render-server.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
1432
|
+
} else if (kind === 'bool') {
|
|
1430
1433
|
buf = buf.slice(0, attrStart);
|
|
1431
1434
|
if (val) buf += `${name}=""`;
|
|
1432
1435
|
state = 'in-tag';
|
package/src/router-client.js
CHANGED
|
@@ -765,6 +765,14 @@ const snapshotCache = new Map();
|
|
|
765
765
|
*/
|
|
766
766
|
function snapshotCurrent(url) {
|
|
767
767
|
const key = cacheKey(url);
|
|
768
|
+
// Let components and app code strip transient state (open overlays, toasts,
|
|
769
|
+
// in-progress wizard steps) from the page BEFORE it is serialized into the
|
|
770
|
+
// back/forward cache, so a later popstate restore shows a clean page rather
|
|
771
|
+
// than, say, a hover-card frozen open (#766, Turbo's `before-cache` contract).
|
|
772
|
+
// Fires SYNCHRONOUSLY on the live DOM right before the outerHTML read, so a
|
|
773
|
+
// handler's mutations are captured; the live edits are invisible because the
|
|
774
|
+
// page is being navigated away from.
|
|
775
|
+
document.dispatchEvent(new CustomEvent('webjs:before-cache', { detail: { url } }));
|
|
768
776
|
// Move-to-front for LRU.
|
|
769
777
|
if (snapshotCache.has(key)) snapshotCache.delete(key);
|
|
770
778
|
/** @type {Snapshot} */
|
package/src/slot.js
CHANGED
|
@@ -436,7 +436,7 @@ export function attachSlotObservers(host) {
|
|
|
436
436
|
// Skip records produced by the framework's own renderer. Each
|
|
437
437
|
// top-level TemplateInstance commit (`createInstance`) and each
|
|
438
438
|
// child-part template swap (`applyChildPart`) inserts and removes
|
|
439
|
-
// its content as a unit bounded by `
|
|
439
|
+
// its content as a unit bounded by `wjm-s`/`wjm-e` comment markers
|
|
440
440
|
// (see render-client.js). When such a record reaches the host's
|
|
441
441
|
// childList observer, treating its addedNodes as authored
|
|
442
442
|
// children pollutes assignedByName with the renderer's own
|