@webjsdev/core 0.7.10 → 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/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-lazy-loader.js +1 -1
- package/dist/webjs-core-lazy-loader.js.map +2 -2
- package/dist/webjs-core.js +3 -3
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +4 -0
- package/index.d.ts +9 -0
- package/index.js +4 -0
- package/package.json +1 -1
- package/src/lazy-loader.js +56 -0
- package/src/optimistic.js +64 -0
- package/src/render-client.js +176 -25
- package/src/router-client.js +763 -37
- package/src/webjs-frame.js +154 -6
- package/src/webjs-stream.js +170 -0
package/src/webjs-frame.js
CHANGED
|
@@ -35,26 +35,174 @@
|
|
|
35
35
|
* frame appears in the response, the router falls through to the
|
|
36
36
|
* normal layout-marker mechanism (and then to full body swap).
|
|
37
37
|
*
|
|
38
|
+
* External targeting (Turbo's `data-turbo-frame`): a trigger (an `<a>`
|
|
39
|
+
* or a `<form>`) that is NOT nested inside the frame can still drive it
|
|
40
|
+
* by carrying `data-webjs-frame="<id>"`. The router resolves the id via
|
|
41
|
+
* `getElementById` in the current document, so an external nav/sidebar
|
|
42
|
+
* link or a filter form drives a content frame it does not enclose. An
|
|
43
|
+
* id that does not resolve to a live `<webjs-frame>` warns once and falls
|
|
44
|
+
* back to a normal navigation (fail-safe, never a wrong-region swap). The
|
|
45
|
+
* reserved token `data-webjs-frame="_top"` on a trigger INSIDE a frame
|
|
46
|
+
* breaks OUT to a full-page navigation instead of swapping the frame.
|
|
47
|
+
*
|
|
48
|
+
* Busy state: while a frame's navigation is in flight the router sets the
|
|
49
|
+
* native `aria-busy="true"` on the frame element and clears it (to
|
|
50
|
+
* `"false"`) on completion, success, failure, or abort, so assistive tech
|
|
51
|
+
* announces the loading state and CSS can style `webjs-frame[aria-busy="true"]`.
|
|
52
|
+
* It also dispatches a bubbling `webjs:frame-busy` event on the frame at
|
|
53
|
+
* both edges (detail `{ frameId, busy: true }` then `{ frameId, busy: false }`),
|
|
54
|
+
* so app code can hook the start and finish.
|
|
55
|
+
*
|
|
38
56
|
* The element is **light DOM**: no shadow root, no slot mechanics.
|
|
39
57
|
* Children are normal light-DOM children that the router replaces
|
|
40
58
|
* via the same keyed reconciler used for layout marker swaps. This
|
|
41
59
|
* means scroll position, input values, and `<details>` open state
|
|
42
60
|
* are preserved for any matched elements inside the frame.
|
|
43
61
|
*
|
|
62
|
+
* With JS disabled a `data-webjs-frame` link is an inert attribute on a
|
|
63
|
+
* plain `<a href>`, so the click is a normal full-page navigation, the
|
|
64
|
+
* correct progressive-enhancement fallback.
|
|
65
|
+
*
|
|
66
|
+
* Self-loading (`src` + `loading`): a frame MAY fetch its own content. With
|
|
67
|
+
* a `src` attribute the frame self-fetches that URL as a frame nav and applies
|
|
68
|
+
* the matching `<webjs-frame id>` subtree from the response into itself, through
|
|
69
|
+
* the SAME router frame-swap path a click-driven frame nav uses (so the busy
|
|
70
|
+
* lifecycle, the navigation-error recovery, and the frame-missing fallback all
|
|
71
|
+
* apply). The `loading` attribute picks WHEN:
|
|
72
|
+
*
|
|
73
|
+
* - `loading="eager"` (or absent, the default): fetch on `connectedCallback`.
|
|
74
|
+
* - `loading="lazy"`: fetch when the frame first scrolls into view, reusing
|
|
75
|
+
* the same IntersectionObserver budget (`rootMargin: '200px'`) as a
|
|
76
|
+
* `static lazy = true` component.
|
|
77
|
+
*
|
|
78
|
+
* A `src` change after connect re-loads. Eager-connect, the lazy observer, and
|
|
79
|
+
* a `src` mutation never double-fire: a per-element loaded/loading guard keyed
|
|
80
|
+
* on the resolved URL coalesces them.
|
|
81
|
+
*
|
|
82
|
+
* PROGRESSIVE ENHANCEMENT CAVEAT: a `src`-driven frame is JS-DEPENDENT. The
|
|
83
|
+
* browser does NOT natively fetch `<webjs-frame src>` (unlike `<iframe>`), so
|
|
84
|
+
* with JS off the frame shows only whatever children were server-rendered into
|
|
85
|
+
* it. Use `src`/`loading` for DEFERRED content (comments, a recommendations
|
|
86
|
+
* rail, an expensive card) where a JS-off empty/placeholder state is acceptable.
|
|
87
|
+
* For content that MUST exist without JS, render it server-side into the frame
|
|
88
|
+
* instead of using `src` (the self-load then replaces those fallback children).
|
|
89
|
+
*
|
|
44
90
|
* @element webjs-frame
|
|
45
91
|
* @attr {string} id: Required. The frame's identifier, used by
|
|
46
|
-
* `closest()` on the client
|
|
92
|
+
* `closest()` on the client, `getElementById` for external
|
|
93
|
+
* `data-webjs-frame` targeting, and `querySelector` on the response.
|
|
94
|
+
* @attr {string} src: Optional. A same-origin URL the frame self-fetches as a
|
|
95
|
+
* frame nav, applying the matching `<webjs-frame id>` subtree into itself.
|
|
96
|
+
* @attr {string} loading: Optional. `eager` (default) fetches `src` on connect;
|
|
97
|
+
* `lazy` fetches when the frame scrolls into view.
|
|
98
|
+
* @fires webjs:frame-busy: Bubbling. Dispatched on the frame when its
|
|
99
|
+
* navigation starts (`detail.busy === true`) and finishes
|
|
100
|
+
* (`detail.busy === false`); `detail.frameId` names the frame.
|
|
47
101
|
*/
|
|
48
102
|
// Defined lazily inside an IIFE so that importing this module on the
|
|
49
103
|
// server (Node) doesn't trip a `HTMLElement is not defined` reference
|
|
50
104
|
// error. Server-side renderers never touch the class: the element
|
|
51
|
-
// is always rendered as `<webjs-frame id=...>` plain
|
|
52
|
-
// renderToString pipeline.
|
|
105
|
+
// is always rendered as `<webjs-frame id=... src=... loading=...>` plain
|
|
106
|
+
// HTML by the renderToString pipeline.
|
|
53
107
|
const WebjsFrame = (typeof HTMLElement !== 'undefined')
|
|
54
108
|
? class WebjsFrame extends HTMLElement {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
109
|
+
constructor() {
|
|
110
|
+
super();
|
|
111
|
+
/**
|
|
112
|
+
* The `src` value most recently loaded (or loading). Guards against a
|
|
113
|
+
* double-load when eager connect, the lazy observer, and a `src`
|
|
114
|
+
* mutation all want to fire: a load only starts when the resolved URL
|
|
115
|
+
* differs from this. `null` means "nothing loaded yet".
|
|
116
|
+
* @type {string | null}
|
|
117
|
+
*/
|
|
118
|
+
this._webjsLoadedSrc = null;
|
|
119
|
+
/**
|
|
120
|
+
* Teardown for the lazy IntersectionObserver subscription, so a
|
|
121
|
+
* disconnect or a `src` change stops observing.
|
|
122
|
+
* @type {(() => void) | null}
|
|
123
|
+
*/
|
|
124
|
+
this._webjsLazyTeardown = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static get observedAttributes() { return ['src']; }
|
|
128
|
+
|
|
129
|
+
connectedCallback() {
|
|
130
|
+
this._webjsScheduleLoad();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
disconnectedCallback() {
|
|
134
|
+
if (this._webjsLazyTeardown) { this._webjsLazyTeardown(); this._webjsLazyTeardown = null; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} name
|
|
139
|
+
* @param {string | null} _old
|
|
140
|
+
* @param {string | null} _next
|
|
141
|
+
*/
|
|
142
|
+
attributeChangedCallback(name, _old, _next) {
|
|
143
|
+
// Only react to a `src` change AFTER the element is connected (the
|
|
144
|
+
// initial attribute parse fires this before connectedCallback; the
|
|
145
|
+
// connect path handles the first load). A live `src` swap re-loads.
|
|
146
|
+
if (name === 'src' && this.isConnected) this._webjsScheduleLoad();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decide whether and when to self-load based on `src` + `loading`,
|
|
151
|
+
* coalescing the three triggers (eager connect, lazy viewport, `src`
|
|
152
|
+
* mutation) so the frame never double-fetches the same URL.
|
|
153
|
+
*/
|
|
154
|
+
_webjsScheduleLoad() {
|
|
155
|
+
const src = this.getAttribute('src');
|
|
156
|
+
// No src: nothing to self-load (a normal swap-anchor frame). Drop any
|
|
157
|
+
// pending lazy observation left over from a previous src.
|
|
158
|
+
if (!src) {
|
|
159
|
+
if (this._webjsLazyTeardown) { this._webjsLazyTeardown(); this._webjsLazyTeardown = null; }
|
|
160
|
+
this._webjsLoadedSrc = null;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Resolve so the dedupe key matches what loadFrame requests, and so a
|
|
164
|
+
// relative `src` compares stably.
|
|
165
|
+
let resolved = src;
|
|
166
|
+
try { resolved = new URL(src, location.href).href; } catch { /* keep raw */ }
|
|
167
|
+
// Already loaded (or loading) this exact URL: no double-load.
|
|
168
|
+
if (resolved === this._webjsLoadedSrc) return;
|
|
169
|
+
|
|
170
|
+
// A new src supersedes any in-flight lazy observation.
|
|
171
|
+
if (this._webjsLazyTeardown) { this._webjsLazyTeardown(); this._webjsLazyTeardown = null; }
|
|
172
|
+
|
|
173
|
+
const lazy = (this.getAttribute('loading') || 'eager').toLowerCase() === 'lazy';
|
|
174
|
+
if (lazy) {
|
|
175
|
+
// Defer the fetch until the frame enters the viewport, reusing the
|
|
176
|
+
// shared per-element IntersectionObserver. Mark the URL as claimed
|
|
177
|
+
// NOW so a redundant schedule (e.g. attributeChanged firing again
|
|
178
|
+
// with the same src) does not re-observe.
|
|
179
|
+
this._webjsLoadedSrc = resolved;
|
|
180
|
+
import('./lazy-loader.js').then(({ observeViewportOnce }) => {
|
|
181
|
+
// Bail if the src changed again while the import was in flight.
|
|
182
|
+
if (this._webjsLoadedSrc !== resolved || !this.isConnected) return;
|
|
183
|
+
this._webjsLazyTeardown = observeViewportOnce(this, () => {
|
|
184
|
+
this._webjsLazyTeardown = null;
|
|
185
|
+
this._webjsLoad(resolved);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
} else {
|
|
189
|
+
this._webjsLoadedSrc = resolved;
|
|
190
|
+
this._webjsLoad(resolved);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run the actual frame self-load through the router's frame-swap path.
|
|
196
|
+
* @param {string} resolved The already-resolved absolute URL.
|
|
197
|
+
*/
|
|
198
|
+
_webjsLoad(resolved) {
|
|
199
|
+
import('./router-client.js').then(({ loadFrame }) => {
|
|
200
|
+
// The src may have changed again (or the frame disconnected) between
|
|
201
|
+
// scheduling and the import resolving; only load if we still own it.
|
|
202
|
+
if (this._webjsLoadedSrc !== resolved || !this.isConnected) return;
|
|
203
|
+
loadFrame(this, resolved);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
58
206
|
}
|
|
59
207
|
: /** @type {any} */ (null);
|
|
60
208
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<webjs-stream action="..." target="...">`: a self-applying surgical
|
|
3
|
+
* DOM-update element (Turbo Streams parity, webjs-shaped).
|
|
4
|
+
*
|
|
5
|
+
* The router's primary mechanism swaps a whole layout-marker region (or a
|
|
6
|
+
* `<webjs-frame>`). That is coarse: a comment-append, an optimistic row
|
|
7
|
+
* removal, or a toast insertion has to redraw a whole region or hand-write
|
|
8
|
+
* DOM JS. `<webjs-stream>` is the element-level grammar for those surgical
|
|
9
|
+
* updates. The payload is plain HTML the browser already parses: a
|
|
10
|
+
* `<webjs-stream>` wrapping a single `<template>`, carrying an `action` and a
|
|
11
|
+
* `target` (an element id) or `targets` (a CSS selector). There is no protocol
|
|
12
|
+
* parser; the element clones its `<template>` content and applies it with
|
|
13
|
+
* native DOM methods, then removes itself.
|
|
14
|
+
*
|
|
15
|
+
* <webjs-stream action="append" target="comments">
|
|
16
|
+
* <template><li>Nice post!</li></template>
|
|
17
|
+
* </webjs-stream>
|
|
18
|
+
*
|
|
19
|
+
* Actions (mirroring Turbo's set):
|
|
20
|
+
*
|
|
21
|
+
* - `append` / `prepend`: add the template content as the last / first
|
|
22
|
+
* children of the target.
|
|
23
|
+
* - `before` / `after`: insert the template content as siblings before /
|
|
24
|
+
* after the target.
|
|
25
|
+
* - `replace`: replace the target element itself with the template content.
|
|
26
|
+
* - `update`: replace the target's children with the template content.
|
|
27
|
+
* - `remove`: remove the target element (no template needed).
|
|
28
|
+
*
|
|
29
|
+
* Two delivery paths share this ONE applier:
|
|
30
|
+
*
|
|
31
|
+
* - **HTTP (content-negotiated form response).** A `<form>` submission rides
|
|
32
|
+
* the client router, which sends `Accept: text/vnd.webjs-stream.html`. When
|
|
33
|
+
* the server answers with that content type, the router applies the
|
|
34
|
+
* `<webjs-stream>` elements in the body surgically (no region swap). With
|
|
35
|
+
* JS OFF the browser sends no such Accept, so the server returns a normal
|
|
36
|
+
* render/redirect and the form degrades to a full-page round-trip. The
|
|
37
|
+
* grammar is therefore additive and progressive-enhancement-safe.
|
|
38
|
+
* - **Live channel (`broadcast()` / `connectWS`).** A server message that is
|
|
39
|
+
* a `<webjs-stream>` HTML string is applied by `renderStream(message)` from
|
|
40
|
+
* a `connectWS` handler, so chat / notifications / presence reuse the same
|
|
41
|
+
* applier instead of bespoke per-app DOM code.
|
|
42
|
+
*
|
|
43
|
+
* The element is inert server-side: `renderToString` emits it as plain
|
|
44
|
+
* `<webjs-stream action=... target=...>` HTML and never touches the class
|
|
45
|
+
* (it is defined only when `HTMLElement` exists). A `<webjs-stream>` that is
|
|
46
|
+
* server-rendered into a page (rather than streamed in) self-applies on
|
|
47
|
+
* hydration exactly as a streamed one does, so there is no separate code path.
|
|
48
|
+
*
|
|
49
|
+
* @element webjs-stream
|
|
50
|
+
* @attr {string} action: Required. One of append / prepend / before / after /
|
|
51
|
+
* replace / update / remove.
|
|
52
|
+
* @attr {string} target: An element id to apply the action to.
|
|
53
|
+
* @attr {string} targets: A CSS selector matching one or more targets (applied
|
|
54
|
+
* to each). `target` and `targets` are mutually exclusive; `target` wins.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** The actions that operate by inserting the template content. */
|
|
58
|
+
const INSERT_ACTIONS = new Set(['append', 'prepend', 'before', 'after', 'replace', 'update']);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Apply one resolved action against one target element.
|
|
62
|
+
* @param {string} action
|
|
63
|
+
* @param {Element} target
|
|
64
|
+
* @param {DocumentFragment | null} frag Cloned template content (null for remove).
|
|
65
|
+
*/
|
|
66
|
+
function applyTo(action, target, frag) {
|
|
67
|
+
switch (action) {
|
|
68
|
+
case 'append': if (frag) target.append(frag); break;
|
|
69
|
+
case 'prepend': if (frag) target.prepend(frag); break;
|
|
70
|
+
case 'before': if (frag) target.before(frag); break;
|
|
71
|
+
case 'after': if (frag) target.after(frag); break;
|
|
72
|
+
case 'replace': if (frag) target.replaceWith(frag); break;
|
|
73
|
+
case 'update': if (frag) target.replaceChildren(frag); break;
|
|
74
|
+
case 'remove': target.remove(); break;
|
|
75
|
+
default: break; // unknown action: no-op
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const WebjsStream = (typeof HTMLElement !== 'undefined')
|
|
80
|
+
? class WebjsStream extends HTMLElement {
|
|
81
|
+
connectedCallback() {
|
|
82
|
+
// Apply once. A streamed-in element runs this on insertion; a
|
|
83
|
+
// server-rendered one runs it on upgrade. Either way it self-removes.
|
|
84
|
+
if (this._webjsApplied) return;
|
|
85
|
+
this._webjsApplied = true;
|
|
86
|
+
this._webjsApply();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_webjsApply() {
|
|
90
|
+
const action = (this.getAttribute('action') || '').toLowerCase();
|
|
91
|
+
const doc = this.ownerDocument || document;
|
|
92
|
+
// Resolve targets: an id via `target`, or a selector via `targets`.
|
|
93
|
+
/** @type {Element[]} */
|
|
94
|
+
let targets = [];
|
|
95
|
+
const id = this.getAttribute('target');
|
|
96
|
+
if (id) {
|
|
97
|
+
const el = doc.getElementById(id);
|
|
98
|
+
if (el) targets = [el];
|
|
99
|
+
} else {
|
|
100
|
+
const sel = this.getAttribute('targets');
|
|
101
|
+
if (sel) {
|
|
102
|
+
try { targets = Array.from(doc.querySelectorAll(sel)); } catch { targets = []; }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// The single child <template> carries the content for an insert action.
|
|
106
|
+
const tpl = INSERT_ACTIONS.has(action)
|
|
107
|
+
? /** @type {HTMLTemplateElement | null} */ (this.querySelector('template'))
|
|
108
|
+
: null;
|
|
109
|
+
try {
|
|
110
|
+
for (const target of targets) {
|
|
111
|
+
// Clone per target so multiple `targets` each get their own nodes.
|
|
112
|
+
const frag = tpl ? /** @type {DocumentFragment} */ (tpl.content.cloneNode(true)) : null;
|
|
113
|
+
applyTo(action, target, frag);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
// The stream element itself never stays in the live DOM.
|
|
117
|
+
this.remove();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
: /** @type {any} */ (null);
|
|
122
|
+
|
|
123
|
+
if (typeof customElements !== 'undefined' && WebjsStream && !customElements.get('webjs-stream')) {
|
|
124
|
+
customElements.define('webjs-stream', WebjsStream);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Apply a server-sent stream payload to the live DOM. The payload is HTML
|
|
129
|
+
* containing one or more `<webjs-stream>` elements (each wrapping a
|
|
130
|
+
* `<template>`); appending them to the document upgrades each one, which
|
|
131
|
+
* self-applies its action and self-removes. Use this from a `connectWS` /
|
|
132
|
+
* `broadcast` message handler so a live channel reuses the same applier as the
|
|
133
|
+
* HTTP path.
|
|
134
|
+
*
|
|
135
|
+
* connectWS('/feed', { onMessage: (m) => renderStream(m) });
|
|
136
|
+
*
|
|
137
|
+
* @param {string | DocumentFragment | Node} input The stream HTML (or parsed nodes).
|
|
138
|
+
* @param {Document} [doc] The document to apply into (defaults to `document`).
|
|
139
|
+
*/
|
|
140
|
+
function renderStream(input, doc) {
|
|
141
|
+
const d = doc || (typeof document !== 'undefined' ? document : null);
|
|
142
|
+
if (!d) return;
|
|
143
|
+
/** @type {DocumentFragment | Node | null} */
|
|
144
|
+
let frag = null;
|
|
145
|
+
if (typeof input === 'string') {
|
|
146
|
+
const tpl = d.createElement('template');
|
|
147
|
+
tpl.innerHTML = input;
|
|
148
|
+
frag = tpl.content;
|
|
149
|
+
} else if (input && /** @type any */ (input).nodeType) {
|
|
150
|
+
frag = input; // a DocumentFragment or a single Node
|
|
151
|
+
} else {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Append ONLY the <webjs-stream> elements (each self-applies + self-removes on
|
|
155
|
+
// connect). Skipping the intervening whitespace text nodes a multi-action
|
|
156
|
+
// payload carries (the parts are joined by newlines) avoids accreting stray
|
|
157
|
+
// text nodes in <body> on a long-lived live channel.
|
|
158
|
+
const found = /** @type any */ (frag).querySelectorAll
|
|
159
|
+
? Array.from(/** @type any */ (frag).querySelectorAll('webjs-stream'))
|
|
160
|
+
: [];
|
|
161
|
+
if (found.length) {
|
|
162
|
+
for (const s of found) d.body.append(s);
|
|
163
|
+
} else {
|
|
164
|
+
// No nested <webjs-stream> found (e.g. a single element passed directly):
|
|
165
|
+
// append the input verbatim so the caller's node still reaches the DOM.
|
|
166
|
+
d.body.append(frag);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { WebjsStream, renderStream };
|