@webjsdev/core 0.7.46 → 0.7.48
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 +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +21 -21
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +5 -1
- package/index.d.ts +4 -1
- package/index.js +5 -1
- package/package.json +2 -2
- package/src/action-seed-client.d.ts +23 -1
- package/src/action-seed-client.js +359 -17
- package/src/component.d.ts +7 -3
- package/src/component.js +69 -0
- package/src/form-action.d.ts +16 -0
- package/src/form-action.js +1128 -33
- package/src/optimistic.js +7 -2
- package/src/render-client.js +978 -111
- package/src/render-server.js +314 -24
- package/src/router-client.js +941 -33
- package/src/webjs-config.d.ts +49 -17
package/index-browser.js
CHANGED
|
@@ -31,6 +31,10 @@ export { escapeText, escapeAttr } from './src/escape.js';
|
|
|
31
31
|
export { notFound, redirect, forbidden, unauthorized, isNotFound, isRedirect, isForbidden, isUnauthorized } from './src/nav.js';
|
|
32
32
|
export { cspNonce } from './src/csp-nonce.js';
|
|
33
33
|
export { asset } from './src/asset-url.js';
|
|
34
|
+
// Form actions (#1155). The field name is shared with the server dispatcher;
|
|
35
|
+
// `setFormActionResolver` is server-only wiring and is stripped here, because
|
|
36
|
+
// a browser stub carries its own identity and needs no resolver.
|
|
37
|
+
export { FORM_ACTION_FIELD, FORM_ACTION_ID_KEY } from './src/form-action.js';
|
|
34
38
|
export { repeat, isRepeat } from './src/repeat.js';
|
|
35
39
|
export { Suspense, isSuspense } from './src/suspense.js';
|
|
36
40
|
export { connectWS } from './src/websocket-client.js';
|
|
@@ -60,7 +64,7 @@ export { optimistic } from './src/optimistic.js';
|
|
|
60
64
|
|
|
61
65
|
// SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
|
|
62
66
|
// first call so async-render hydration does not re-fetch the SSR'd data.
|
|
63
|
-
export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
|
|
67
|
+
export { takeSeed, scanSeeds, seedStats, SEED_MISS } from './src/action-seed-client.js';
|
|
64
68
|
// Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
|
|
65
69
|
// browser-cache eviction after a mutation. Inert server-side.
|
|
66
70
|
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
package/index.d.ts
CHANGED
|
@@ -52,6 +52,8 @@ export type {
|
|
|
52
52
|
WebjsRedirectRule,
|
|
53
53
|
WebjsTrailingSlash,
|
|
54
54
|
WebjsCspConfig,
|
|
55
|
+
WebjsDoctorConfig,
|
|
56
|
+
WebjsDoctorSeverity,
|
|
55
57
|
} from './src/webjs-config.d.ts';
|
|
56
58
|
|
|
57
59
|
// Compile-time serializability typing for server actions (#488): the opt-in
|
|
@@ -105,7 +107,8 @@ export { Task, TaskStatus } from './src/task.js';
|
|
|
105
107
|
export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
|
|
106
108
|
export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
|
|
107
109
|
export { asset, setAssetUrlProvider } from './src/asset-url.js';
|
|
108
|
-
export {
|
|
110
|
+
export { FORM_ACTION_FIELD, FORM_ACTION_ID_KEY, setFormActionResolver } from './src/form-action.js';
|
|
111
|
+
export { takeSeed, scanSeeds, seedStats, SEED_MISS } from './src/action-seed-client.js';
|
|
109
112
|
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
|
110
113
|
// Client action-abort plumbing (#492): a superseded async render aborts its
|
|
111
114
|
// in-flight action fetches. Inert server-side.
|
package/index.js
CHANGED
|
@@ -16,6 +16,10 @@ export { escapeText, escapeAttr } from './src/escape.js';
|
|
|
16
16
|
export { notFound, redirect, forbidden, unauthorized, isNotFound, isRedirect, isForbidden, isUnauthorized } from './src/nav.js';
|
|
17
17
|
export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
|
|
18
18
|
export { asset, setAssetUrlProvider } from './src/asset-url.js';
|
|
19
|
+
// Form actions (#1155). `FORM_ACTION_FIELD` is the hidden field both renderers
|
|
20
|
+
// emit and the server's form dispatcher reads; `setFormActionResolver` is the
|
|
21
|
+
// server-only wiring that maps a real action function back to its identity.
|
|
22
|
+
export { FORM_ACTION_FIELD, FORM_ACTION_ID_KEY, setFormActionResolver } from './src/form-action.js';
|
|
19
23
|
export { repeat, isRepeat } from './src/repeat.js';
|
|
20
24
|
export { Suspense, isSuspense } from './src/suspense.js';
|
|
21
25
|
export { connectWS } from './src/websocket-client.js';
|
|
@@ -36,7 +40,7 @@ export { optimistic } from './src/optimistic.js';
|
|
|
36
40
|
|
|
37
41
|
// SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
|
|
38
42
|
// first call so async-render hydration does not re-fetch. Inert server-side.
|
|
39
|
-
export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
|
|
43
|
+
export { takeSeed, scanSeeds, seedStats, SEED_MISS } from './src/action-seed-client.js';
|
|
40
44
|
// Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
|
|
41
45
|
// browser-cache eviction after a mutation. Inert server-side.
|
|
42
46
|
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.48",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "The runtime for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Ships the html and css template tags, the WebComponent base class, signals, directives, and the isomorphic renderers.",
|
|
6
6
|
"types": "./index.d.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": {
|
|
@@ -7,11 +7,33 @@ export const SEED_MISS: unique symbol;
|
|
|
7
7
|
* Merge any seeds found under `root` (or the whole document) into the global
|
|
8
8
|
* consume-once store, removing the carriers. Reads the page-level
|
|
9
9
|
* `#__webjs-seeds` JSON block and per-element `[data-webjs-seed]` carriers.
|
|
10
|
+
*
|
|
11
|
+
* A DETACHED root means a new PAGE is arriving, which evicts whatever the
|
|
12
|
+
* outgoing page left unconsumed. Pass `{ frame: true }` for a `<webjs-frame>`
|
|
13
|
+
* subtree swap, which is not a page navigation and must leave page state alone.
|
|
10
14
|
*/
|
|
11
|
-
export function scanSeeds(
|
|
15
|
+
export function scanSeeds(
|
|
16
|
+
root?: ParentNode,
|
|
17
|
+
opts?: { frame?: boolean },
|
|
18
|
+
): void;
|
|
12
19
|
|
|
13
20
|
/**
|
|
14
21
|
* Look up and CONSUME the seed for an action call, or return `SEED_MISS`.
|
|
15
22
|
* Keyed `hash/fn/argsKey`; the first call lazily scans the initial document.
|
|
16
23
|
*/
|
|
17
24
|
export function takeSeed(hash: string, fnName: string, argsKey: string): unknown;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The cumulative seed counters for this page session (#1309). `ingested` and
|
|
28
|
+
* `replaced` are what `scanSeeds` merged; `hits` and `misses` are what the
|
|
29
|
+
* generated RPC stubs asked for; `pending` is what is still unconsumed.
|
|
30
|
+
*/
|
|
31
|
+
export function seedStats(): {
|
|
32
|
+
ingested: number;
|
|
33
|
+
replaced: number;
|
|
34
|
+
hits: number;
|
|
35
|
+
misses: number;
|
|
36
|
+
/** The provable subset of `misses`: a call for an action this page seeded under other arguments. */
|
|
37
|
+
keyMisses: number;
|
|
38
|
+
pending: number;
|
|
39
|
+
};
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The server (`@webjsdev/server`'s `action-seed.js`) serializes each action
|
|
5
5
|
* result invoked during SSR into a `<script type="application/json"
|
|
6
|
-
* id="__webjs-seeds">` block
|
|
7
|
-
*
|
|
6
|
+
* id="__webjs-seeds">` block. A per-element `data-webjs-seed` carrier is also
|
|
7
|
+
* READ here, for a future per-component / streamed-region seeding; no server
|
|
8
|
+
* path emits one today, and a streamed render emits no seeds at all.
|
|
9
|
+
* On the client, the generated RPC stub
|
|
8
10
|
* (`actions.js`) calls `takeSeed(hash, fn, argsKey)` on its FIRST invocation: a
|
|
9
11
|
* hit resolves the action synchronously with the SSR value (no RPC, no flicker
|
|
10
12
|
* on hydration); a miss falls through to the normal RPC.
|
|
@@ -31,31 +33,232 @@ export const SEED_MISS = Symbol('webjs.seed.miss');
|
|
|
31
33
|
/** One-time eager scan guard (the initial-load document is scanned lazily). */
|
|
32
34
|
let scannedInitial = false;
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Cumulative counters (#1309). ALWAYS on: WebJs is no-build on the server, and
|
|
38
|
+
* the one build that exists (the core browser bundle) cannot express a dev gate,
|
|
39
|
+
* since esbuild substitutes `process.env.NODE_ENV` with "production" and
|
|
40
|
+
* `publicEnvShim` always defines `window.process.env`, so a NODE_ENV guard in
|
|
41
|
+
* this bundle is a constant that reads the wrong way round. Four integer
|
|
42
|
+
* increments on a path that already awaits `stringify(args)` and usually a
|
|
43
|
+
* `fetch`, so nothing is gated here; only the REPORTING is, by a server-emitted
|
|
44
|
+
* marker (see `readDevMarker`).
|
|
45
|
+
*/
|
|
46
|
+
const stats = { ingested: 0, replaced: 0, hits: 0, misses: 0, keyMisses: 0 };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The `hash/fn` of every action this page SEEDED, whatever arguments it used.
|
|
50
|
+
*
|
|
51
|
+
* This is what makes a miss provable. A miss on its own is not evidence of
|
|
52
|
+
* anything: every action call routes through `takeSeed`, including ones that
|
|
53
|
+
* were never SSR-invoked and never could have been seeded (a mutation, a `Task`
|
|
54
|
+
* autorun, a `connectedCallback` read, an `optimistic()` call). But a miss on an
|
|
55
|
+
* action the server DID invoke during this render, under some other argument
|
|
56
|
+
* list, is a genuine key mismatch and nothing else: the action is reachable, it
|
|
57
|
+
* was seeded, and this call still asked for a key the page does not carry.
|
|
58
|
+
*
|
|
59
|
+
* Keyed on the first two segments, which is exact: the file hash and the export
|
|
60
|
+
* name are both `/`-free, and only the serialized argument list can contain one.
|
|
61
|
+
*/
|
|
62
|
+
const seedFns = new Set();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The open report window, or null. Each scan OPENS one and CLOSES the previous,
|
|
66
|
+
* so a window measures exactly the page it was opened for.
|
|
67
|
+
*
|
|
68
|
+
* The window has to be the hydration window (scan to idle), because a miss AFTER
|
|
69
|
+
* it is correct behaviour: the seed is consume-once, so a deliberate refetch or
|
|
70
|
+
* an argument change is SUPPOSED to miss. Measure any wider and correct misses
|
|
71
|
+
* are reported as defects, which is what trains a developer to filter the
|
|
72
|
+
* channel out.
|
|
73
|
+
*
|
|
74
|
+
* "Wider" has two directions and both were live at some point in this PR. Late:
|
|
75
|
+
* snapshotting at report time rather than schedule time banks post-hydration
|
|
76
|
+
* misses and charges them to the next page. Early: letting a report stay open
|
|
77
|
+
* across a soft navigation folds the NEXT page's seeds and calls into the
|
|
78
|
+
* previous page's numbers, so a healthy page gets a defect line and the page
|
|
79
|
+
* that actually had one gets no report at all. Closing on the next scan bounds
|
|
80
|
+
* it from both ends, and the marker rides the window for the same reason the
|
|
81
|
+
* counters do.
|
|
82
|
+
*
|
|
83
|
+
* `epoch` lets a scheduled callback tell whether its window is still the open
|
|
84
|
+
* one; a window closed early (by the next scan, or by `__resetSeeds`) leaves a
|
|
85
|
+
* callback behind that must be a no-op rather than report a window it does not
|
|
86
|
+
* own.
|
|
87
|
+
* @type {{ epoch: number, hits: number, misses: number, keyMisses: number, merged: number, marker: string } | null}
|
|
88
|
+
*/
|
|
89
|
+
let openWindow = null;
|
|
90
|
+
/** Monotonic window id, so a stale callback can recognise itself. */
|
|
91
|
+
let windowEpoch = 0;
|
|
92
|
+
|
|
34
93
|
/**
|
|
35
94
|
* Merge any seeds found under `root` into the global store, then remove the
|
|
36
|
-
* carriers so a re-scan
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
95
|
+
* carriers so a re-scan never re-ingests stale data. Reads both the page-level
|
|
96
|
+
* `#__webjs-seeds` JSON block and per-element `[data-webjs-seed]` carriers.
|
|
97
|
+
* Idempotent and fail-open: a malformed payload is skipped, never thrown.
|
|
98
|
+
*
|
|
99
|
+
* A DETACHED root means a new PAGE is arriving (the router's `applySwap` passes
|
|
100
|
+
* one), which is what ends the outgoing page's report window and discards its
|
|
101
|
+
* leftovers. A `<webjs-frame>` swap goes through the same `applySwap` but is NOT
|
|
102
|
+
* a page navigation, so the router flags it and this leaves every bit of page
|
|
103
|
+
* state alone; see `opts.frame`.
|
|
40
104
|
* @param {ParentNode & { querySelectorAll?: Function }} [root]
|
|
105
|
+
* @param {{ frame?: boolean }} [opts] `frame: true` marks a `<webjs-frame>`
|
|
106
|
+
* subtree swap rather than a page navigation.
|
|
41
107
|
*/
|
|
42
|
-
export function scanSeeds(root) {
|
|
43
|
-
const
|
|
108
|
+
export function scanSeeds(root, opts) {
|
|
109
|
+
const live = typeof document !== 'undefined' ? document : null;
|
|
110
|
+
const scope = root || live;
|
|
44
111
|
if (!scope || typeof scope.querySelectorAll !== 'function') return;
|
|
45
|
-
|
|
112
|
+
|
|
113
|
+
// A FRAME swap is not a page navigation: the page around the frame is still
|
|
114
|
+
// the page on screen. So touch no page state at all, no window closed or
|
|
115
|
+
// opened and no lazy-scan suppression, and DISCARD whatever the parse carries.
|
|
116
|
+
//
|
|
117
|
+
// The router has to say which it is, because the parse cannot be recognised
|
|
118
|
+
// from its own shape. `ssr.js` slices out a bare frame subtree ONLY when the
|
|
119
|
+
// id was found and the render did not stream; in every other case it falls
|
|
120
|
+
// through and serves the WHOLE PAGE, seed block and all, so a frame response
|
|
121
|
+
// is not reliably distinguishable from a page that seeded nothing. Guessing
|
|
122
|
+
// failed in both directions in turn: treating it as a page killed the report
|
|
123
|
+
// for the whole page the frame sits on, and attributing the window to the live
|
|
124
|
+
// page made it report a CAUSE that is provably false, since none of the causes
|
|
125
|
+
// describes a frame response. (The response's own `X-Webjs-Seed` is no help
|
|
126
|
+
// either: the isolable case returns before the header is set, and the three
|
|
127
|
+
// fallthrough shapes below carry the SURROUNDING page's header, which says
|
|
128
|
+
// nothing about the frame.)
|
|
129
|
+
//
|
|
130
|
+
// Discarding is right for every shape the fallthrough produces, and they are
|
|
131
|
+
// not all the same shape:
|
|
132
|
+
// - ISOLABLE (id found, buffered): the subtree was sliced before the block
|
|
133
|
+
// was appended, so there is nothing to discard.
|
|
134
|
+
// - STREAMED: the whole page is served and the frame IS in it, so the swap
|
|
135
|
+
// SUCCEEDS. In dev the page carries a marker-only block, which holds no
|
|
136
|
+
// seeds anyway.
|
|
137
|
+
// - #241 HTML CACHE HIT: the cached page is returned before the slice, so the
|
|
138
|
+
// parse carries a real, populated block, and the swap succeeds. Those seeds
|
|
139
|
+
// describe the whole page, not the one region being swapped in, and the
|
|
140
|
+
// page on screen already has its own.
|
|
141
|
+
// - ID MISSING: the whole page is served, the router dispatches
|
|
142
|
+
// `webjs:frame-missing`, and the response is thrown away entirely.
|
|
143
|
+
//
|
|
144
|
+
// Ingesting in the last three would put another response's seeds in the store,
|
|
145
|
+
// where last-write-wins hands them to a component on the page still on screen,
|
|
146
|
+
// which is the exact "a hit disagrees with the paint" class this feature
|
|
147
|
+
// promises cannot happen.
|
|
148
|
+
if (opts && opts.frame === true) {
|
|
149
|
+
drainCarriers(scope, true);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Close the previous page's window BEFORE ingesting anything: the moment this
|
|
154
|
+
// page's content arrives is exactly where the previous page's numbers stop. Do
|
|
155
|
+
// it after the ingest loops instead and this scan's seeds are already counted
|
|
156
|
+
// into the window being closed, which is the whole defect.
|
|
157
|
+
closeReportWindow();
|
|
158
|
+
|
|
159
|
+
// Is this root the live document, or part of it? A live root (the lazy initial
|
|
160
|
+
// scan) or a live subtree is not an incoming page, and must not trigger the
|
|
161
|
+
// discard below: it would strip the very subtree being scanned.
|
|
162
|
+
const scopeIsLive = scope === live;
|
|
163
|
+
const scopeInLive = !!live && !scopeIsLive && typeof live.contains === 'function' && live.contains(scope);
|
|
164
|
+
const incomingPage = !!live && !scopeIsLive && !scopeInLive;
|
|
165
|
+
|
|
166
|
+
// The live document can still be holding the OUTGOING page's block, because
|
|
167
|
+
// the initial scan is lazy (it runs on the first `takeSeed`) and a page whose
|
|
168
|
+
// async components all elided never triggers it, while the block sits after
|
|
169
|
+
// the body content outside every boundary range, so no swap removes it either.
|
|
170
|
+
//
|
|
171
|
+
// Strip it WITHOUT ingesting. Those values belong to a page that is being
|
|
172
|
+
// replaced, so nothing on the incoming page should ever receive one: leaving
|
|
173
|
+
// them for the lazy scan to ingest LATER is what let a departed page's value
|
|
174
|
+
// win a key under last-write-wins and contradict the paint. Ingesting them
|
|
175
|
+
// HERE would order them correctly but keep them forever, because the very
|
|
176
|
+
// reason the block went unconsumed is that its components elided, so nothing
|
|
177
|
+
// will ever call `takeSeed` for those keys and nothing but a hit deletes one.
|
|
178
|
+
// That would grow the store by a whole page payload per navigation, in
|
|
179
|
+
// production, and inflate the "still unconsumed" figure in the dev line with
|
|
180
|
+
// keys from pages the developer has already left. The cost of discarding is
|
|
181
|
+
// that an in-flight `async render()` from the outgoing page misses and
|
|
182
|
+
// refetches, which is a round-trip rather than wrong data.
|
|
183
|
+
if (incomingPage) {
|
|
184
|
+
drainCarriers(live, true);
|
|
185
|
+
// And evict what the outgoing page ALREADY ingested. Stripping the DOM
|
|
186
|
+
// carrier is not enough: a page carrying an elided component alongside a
|
|
187
|
+
// shipping one has its WHOLE block ingested by the lazy scan the shipping
|
|
188
|
+
// one triggers, so the elided component's keys are in the store with no
|
|
189
|
+
// carrier left to strip and nothing that will ever consume them. Navigate to
|
|
190
|
+
// a page that emits no seed for such a key (a streamed page emits no block
|
|
191
|
+
// at all) and a component calling the same action with the same arguments
|
|
192
|
+
// gets a HIT carrying the DEPARTED page's value, painted over the fresh SSR
|
|
193
|
+
// HTML. That is the same "a hit disagrees with the paint" class the drain
|
|
194
|
+
// above closes, reached through the store instead of through the DOM, so it
|
|
195
|
+
// has to close here too or the guarantee is not one.
|
|
196
|
+
//
|
|
197
|
+
// Everything still held at this moment belongs to the page being replaced:
|
|
198
|
+
// a hit deletes its key, so anything left is unconsumed. Dropping it costs
|
|
199
|
+
// an in-flight `async render()` from the outgoing page a round-trip, never
|
|
200
|
+
// wrong data.
|
|
201
|
+
seeds.clear();
|
|
202
|
+
seedFns.clear();
|
|
203
|
+
}
|
|
204
|
+
// The lazy scan must not run again once the live document has been handled: a
|
|
205
|
+
// second pass finds nothing to ingest but would close the window this scan is
|
|
206
|
+
// about to open. Only when it HAS been handled, though. A live SUBTREE scan
|
|
207
|
+
// leaves the rest of the document unscanned, so the lazy pass still owes it.
|
|
208
|
+
if (scopeIsLive || incomingPage) scannedInitial = true;
|
|
209
|
+
|
|
210
|
+
// MERGED is `ingested + replaced`, not `ingested`: a key already in the store
|
|
211
|
+
// counts as a replacement, so a scan whose seeds all replace unconsumed ones
|
|
212
|
+
// would otherwise measure as zero and claim the page carried no seeds while
|
|
213
|
+
// naming one. A page navigation can no longer produce that shape (it evicts
|
|
214
|
+
// first), but a re-scan of the same document still can, and the count has to
|
|
215
|
+
// mean "merged by this scan" either way.
|
|
216
|
+
const mergedBefore = stats.ingested + stats.replaced;
|
|
217
|
+
startReportWindow(drainCarriers(scope), mergedBefore);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Strip every seed carrier under `scope`, returning the dev marker it carried
|
|
222
|
+
* (the page-level block's, the only carrier that has one) or null. Ingests the
|
|
223
|
+
* values unless `discard` is set, which is how an outgoing page's leftovers are
|
|
224
|
+
* removed without ever entering the store.
|
|
225
|
+
* @param {ParentNode & { querySelectorAll: Function }} scope
|
|
226
|
+
* @param {boolean} [discard] strip the carriers but drop their values
|
|
227
|
+
* @returns {string | null}
|
|
228
|
+
*/
|
|
229
|
+
function drainCarriers(scope, discard) {
|
|
230
|
+
let marker = null;
|
|
231
|
+
// Page-level JSON block(s). The dev marker rides this carrier only, and it is
|
|
232
|
+
// THIS scan's marker that decides whether there is a report to make and what
|
|
233
|
+
// cause it names.
|
|
46
234
|
for (const el of scope.querySelectorAll('script[type="application/json"]#__webjs-seeds, script[type="application/json"][data-webjs-seeds]')) {
|
|
47
|
-
|
|
235
|
+
const m = readDevMarker(el.getAttribute?.('data-webjs-dev'));
|
|
236
|
+
if (m !== null) marker = m;
|
|
237
|
+
ingest(discard ? null : el.textContent, el);
|
|
48
238
|
}
|
|
49
239
|
// Per-element carriers (streamed boundaries / future per-component seeding).
|
|
50
240
|
for (const el of scope.querySelectorAll('[data-webjs-seed]')) {
|
|
51
|
-
ingest(el.getAttribute('data-webjs-seed'), el, () => el.removeAttribute('data-webjs-seed'));
|
|
241
|
+
ingest(discard ? null : el.getAttribute('data-webjs-seed'), el, () => el.removeAttribute('data-webjs-seed'));
|
|
52
242
|
}
|
|
243
|
+
return marker;
|
|
53
244
|
}
|
|
54
245
|
|
|
55
246
|
/**
|
|
56
|
-
* Parse one serialized seed payload and merge it
|
|
57
|
-
*
|
|
58
|
-
*
|
|
247
|
+
* Parse one serialized seed payload and merge it, then run `cleanup` (or remove
|
|
248
|
+
* the element).
|
|
249
|
+
*
|
|
250
|
+
* LAST write wins. A hit DELETES its key (`takeSeed`), so a duplicate can only
|
|
251
|
+
* mean "a later carrier emitted a seed for a key an earlier one left
|
|
252
|
+
* unconsumed", and the later carrier is the one whose paint is on screen.
|
|
253
|
+
*
|
|
254
|
+
* Note what this rule is NOT load-bearing for any more. It was introduced for
|
|
255
|
+
* the cross-PAGE case, where a departed render's value could win a shared key;
|
|
256
|
+
* that case is now closed at the source, because an incoming page evicts the
|
|
257
|
+
* outgoing page's carriers AND its already-ingested seeds before ingesting its
|
|
258
|
+
* own, so no cross-render duplicate can reach here at all. What remains is the
|
|
259
|
+
* within-page case: two carriers in one scan, or a re-scan of the same document,
|
|
260
|
+
* where the later one is still the right answer and first-write-wins would still
|
|
261
|
+
* be the wrong one.
|
|
59
262
|
* @param {string | null} raw
|
|
60
263
|
* @param {Element} el
|
|
61
264
|
* @param {() => void} [cleanup]
|
|
@@ -65,7 +268,13 @@ function ingest(raw, el, cleanup) {
|
|
|
65
268
|
try {
|
|
66
269
|
const obj = parse(raw);
|
|
67
270
|
if (obj && typeof obj === 'object') {
|
|
68
|
-
for (const k in obj)
|
|
271
|
+
for (const k in obj) {
|
|
272
|
+
if (seeds.has(k)) stats.replaced++;
|
|
273
|
+
else stats.ingested++;
|
|
274
|
+
seeds.set(k, obj[k]);
|
|
275
|
+
const cut = k.indexOf('/', k.indexOf('/') + 1);
|
|
276
|
+
if (cut > 0) seedFns.add(k.slice(0, cut));
|
|
277
|
+
}
|
|
69
278
|
}
|
|
70
279
|
} catch {
|
|
71
280
|
// Malformed payload: ignore, the stub re-fetches.
|
|
@@ -75,11 +284,116 @@ function ingest(raw, el, cleanup) {
|
|
|
75
284
|
else el.remove?.();
|
|
76
285
|
}
|
|
77
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Read the server's dev marker off one carrier, the ONLY dev signal this bundle
|
|
289
|
+
* can trust (a `process.env.NODE_ENV` comparison here is a compile-time
|
|
290
|
+
* constant, see `stats`). Absent in production, so the reporting below never
|
|
291
|
+
* runs there. Pure: the value belongs to the scan that read it, never to the
|
|
292
|
+
* module.
|
|
293
|
+
* @param {string | null | undefined} v the `data-webjs-dev` attribute value
|
|
294
|
+
* @returns {string | null} the marker, or null when this carrier had none
|
|
295
|
+
*/
|
|
296
|
+
function readDevMarker(v) {
|
|
297
|
+
if (typeof v !== 'string') return null;
|
|
298
|
+
return v || 'ok';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Open the window for this scan. Called at the END of a scan, once the merged
|
|
303
|
+
* count for it is known; the window it supersedes was already closed at the TOP
|
|
304
|
+
* of the scan, so a markerless scan still ends the previous page's window.
|
|
305
|
+
* @param {string | null} scanMarker THIS scan's `data-webjs-dev` marker, or null
|
|
306
|
+
* @param {number} mergedBefore merged-seed count as it stood before this scan
|
|
307
|
+
*/
|
|
308
|
+
function startReportWindow(scanMarker, mergedBefore) {
|
|
309
|
+
// The previous window was already closed at the top of the scan, before any
|
|
310
|
+
// ingest. No marker at all means there is nothing to report on: production
|
|
311
|
+
// never emits one, and a back/forward restore of a page that WAS scanned
|
|
312
|
+
// carries no block, because it was stripped before the snapshot was
|
|
313
|
+
// serialized. A restore of a page that was NEVER scanned does carry one, and
|
|
314
|
+
// that block is that page's own, so ingesting it and reporting on it are both
|
|
315
|
+
// right.
|
|
316
|
+
if (scanMarker === null) return;
|
|
317
|
+
const epoch = ++windowEpoch;
|
|
318
|
+
openWindow = { epoch, hits: stats.hits, misses: stats.misses, keyMisses: stats.keyMisses, merged: mergedBefore, marker: scanMarker };
|
|
319
|
+
const run = () => {
|
|
320
|
+
if (!openWindow || openWindow.epoch !== epoch) return; // already closed; not ours
|
|
321
|
+
closeReportWindow();
|
|
322
|
+
};
|
|
323
|
+
try {
|
|
324
|
+
if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 1000 });
|
|
325
|
+
else setTimeout(run, 250);
|
|
326
|
+
} catch {
|
|
327
|
+
openWindow = null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Close the open window and report on it. A no-op when none is open. */
|
|
332
|
+
function closeReportWindow() {
|
|
333
|
+
const w = openWindow;
|
|
334
|
+
openWindow = null;
|
|
335
|
+
if (!w) return;
|
|
336
|
+
try { reportSeeds(w); } catch { /* diagnostics never break a page */ }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* One dev console line per page view, and ONLY on a defect. A healthy line every
|
|
341
|
+
* navigation trains a developer to filter the channel out, and the healthy
|
|
342
|
+
* numbers are already on the server's access-log line for every request.
|
|
343
|
+
*/
|
|
344
|
+
function reportSeeds(w) {
|
|
345
|
+
const hits = stats.hits - w.hits;
|
|
346
|
+
const misses = stats.misses - w.misses;
|
|
347
|
+
const merged = (stats.ingested + stats.replaced) - w.merged;
|
|
348
|
+
const keyMisses = stats.keyMisses - w.keyMisses;
|
|
349
|
+
if (misses === 0) return;
|
|
350
|
+
// Report ONLY a cause this side can prove. A miss is not evidence on its own:
|
|
351
|
+
// EVERY action call routes through `takeSeed`, including ones that were never
|
|
352
|
+
// SSR-invoked and never could have been seeded (a mutation, a `Task` autorun,
|
|
353
|
+
// a `connectedCallback` read, an `optimistic()` call). Warning on those tells
|
|
354
|
+
// a developer whose code is correct to go audit it, on every page view, with
|
|
355
|
+
// no way to silence it, which is the false alarm that trains people to filter
|
|
356
|
+
// the channel out.
|
|
357
|
+
//
|
|
358
|
+
// Two shapes are provable. A `streamed` or `drop` marker is the SERVER saying
|
|
359
|
+
// no seed could ride the page, so every miss on it is explained. And a miss on
|
|
360
|
+
// an action this page DID seed under other arguments (`seedFns`) is a genuine
|
|
361
|
+
// key mismatch, because the action is demonstrably reachable and seeded. A
|
|
362
|
+
// miss on an action that was never seeded at all is left alone: that is the
|
|
363
|
+
// ambiguous case, and the server's `X-Webjs-Seed` states it unambiguously.
|
|
364
|
+
const cause = w.marker === 'streamed'
|
|
365
|
+
? 'This page streams (a Suspense or <webjs-suspense> boundary), and a streamed render emits no seeds, so every action call on it goes to the network.'
|
|
366
|
+
: w.marker === 'drop'
|
|
367
|
+
? 'The page\'s seeds could not be serialized, so the whole block was dropped. Something an action returned is not serializer-safe; the server response reports collected above emitted.'
|
|
368
|
+
: keyMisses === 0
|
|
369
|
+
? null
|
|
370
|
+
: 'The key is the action file hash plus the function name plus the serialized arguments, so an argument the SSR render never used misses (a deliberate refetch after hydration misses too, and is expected). A miss on an action this page never seeded is NOT counted here, because a mutation or a client-only read could never have been seeded.';
|
|
371
|
+
if (cause === null) return;
|
|
372
|
+
// The number and the predicate have to agree. For a server-asserted cause the
|
|
373
|
+
// marker explains EVERY miss, so the total is the right figure and "went to
|
|
374
|
+
// the network" is the right claim. For a key mismatch only the provable subset
|
|
375
|
+
// is explained, and calling that subset the round-trip count understates the
|
|
376
|
+
// traffic: a page mixing one key mismatch with one mutation had 2 calls go out
|
|
377
|
+
// while the line said 1. So that case reports its own count against its own
|
|
378
|
+
// claim, and never speaks for the misses it cannot account for.
|
|
379
|
+
const serverAsserted = w.marker === 'streamed' || w.marker === 'drop';
|
|
380
|
+
const headline = serverAsserted
|
|
381
|
+
? `${misses} of ${hits + misses} action call(s) in the hydration window went to the network`
|
|
382
|
+
: `${keyMisses} action call(s) in the hydration window asked for a key this page seeded under DIFFERENT arguments`;
|
|
383
|
+
console.warn(
|
|
384
|
+
`[webjs] SSR action seeding: ${headline} `
|
|
385
|
+
+ `(${merged} seed(s) on this page, ${seeds.size} still unconsumed). ${cause} `
|
|
386
|
+
+ 'See https://webjs.dev/docs/data-fetching for the seeding reference.',
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
78
390
|
/**
|
|
79
391
|
* Look up and CONSUME the seed for an action call. Returns the seeded value
|
|
80
392
|
* (removing it) on a hit, or `SEED_MISS` when there is none. The first call
|
|
81
393
|
* lazily scans the initial document, so the boot path needs no wiring; the
|
|
82
|
-
* router calls `scanSeeds(
|
|
394
|
+
* router calls `scanSeeds(doc)` with the DETACHED parse of content that arrives
|
|
395
|
+
* later, which is also what tells this module a new page is replacing the one on
|
|
396
|
+
* screen.
|
|
83
397
|
* @param {string} hash the action file's hash (the RPC endpoint hash)
|
|
84
398
|
* @param {string} fnName the exported action name
|
|
85
399
|
* @param {string} argsKey `stringify(args)`, computed by the stub with the same
|
|
@@ -95,13 +409,41 @@ export function takeSeed(hash, fnName, argsKey) {
|
|
|
95
409
|
if (seeds.has(key)) {
|
|
96
410
|
const v = seeds.get(key);
|
|
97
411
|
seeds.delete(key);
|
|
412
|
+
stats.hits++;
|
|
98
413
|
return v;
|
|
99
414
|
}
|
|
415
|
+
stats.misses++;
|
|
416
|
+
// A miss on an action this page DID seed under other arguments is the only
|
|
417
|
+
// miss the client can call a defect; see `seedFns`.
|
|
418
|
+
if (seedFns.has(`${hash}/${fnName}`)) stats.keyMisses++;
|
|
100
419
|
return SEED_MISS;
|
|
101
420
|
}
|
|
102
421
|
|
|
103
|
-
/**
|
|
422
|
+
/**
|
|
423
|
+
* The cumulative seed counters for this page session (#1309). `ingested` and
|
|
424
|
+
* `replaced` are what `scanSeeds` merged; `hits` and `misses` are what the
|
|
425
|
+
* generated RPC stubs asked for; `pending` is what is still in the store
|
|
426
|
+
* unconsumed (a non-zero `pending` at rest usually means the seeding component
|
|
427
|
+
* elided, so nothing on the client was ever going to call it).
|
|
428
|
+
* `keyMisses` is the provable subset of `misses`: a call for an action this page
|
|
429
|
+
* DID seed under other arguments, which is the only miss the client can call a
|
|
430
|
+
* defect (see `seedFns`).
|
|
431
|
+
* @returns {{ ingested: number, replaced: number, hits: number, misses: number, keyMisses: number, pending: number }}
|
|
432
|
+
*/
|
|
433
|
+
export function seedStats() {
|
|
434
|
+
return { ...stats, pending: seeds.size };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Test seam: drop all seeds and reset the lazy-scan guard + the counters. */
|
|
104
438
|
export function __resetSeeds() {
|
|
105
439
|
seeds.clear();
|
|
106
440
|
scannedInitial = false;
|
|
441
|
+
stats.ingested = 0;
|
|
442
|
+
stats.replaced = 0;
|
|
443
|
+
stats.hits = 0;
|
|
444
|
+
stats.misses = 0;
|
|
445
|
+
stats.keyMisses = 0;
|
|
446
|
+
seedFns.clear();
|
|
447
|
+
openWindow = null; // drop it silently; a reset is not a page view
|
|
448
|
+
windowEpoch++; // and burn any in-flight callback rather than let it report
|
|
107
449
|
}
|
package/src/component.d.ts
CHANGED
|
@@ -160,9 +160,13 @@ interface WebComponentStatics {
|
|
|
160
160
|
/**
|
|
161
161
|
* Force this component to ship (hydrate) even when the elision analyser would
|
|
162
162
|
* drop it as display-only. The escape hatch for interactivity the analyser
|
|
163
|
-
* cannot see statically:
|
|
164
|
-
* rule in an external stylesheet outside the module graph
|
|
165
|
-
* the
|
|
163
|
+
* cannot see statically: an OBSERVER that computes the tag it waits for, a
|
|
164
|
+
* `:defined` rule in an external stylesheet outside the module graph, or a
|
|
165
|
+
* consumer reaching the element through a string selector. It
|
|
166
|
+
* cannot rescue a component whose OWN registration tag is computed, since
|
|
167
|
+
* the scanner requires a literal tag (invariant 3) and never sees that
|
|
168
|
+
* component at all. Leave unset for the automatic verdict; inspect it with
|
|
169
|
+
* `webjs elision`.
|
|
166
170
|
*/
|
|
167
171
|
interactive?: boolean;
|
|
168
172
|
register(tag: string): void;
|
package/src/component.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render as clientRender } from './render-client.js';
|
|
2
2
|
import { setActiveActionSignal } from './action-abort-client.js';
|
|
3
|
+
import { carriesFunction } from './form-action.js';
|
|
3
4
|
import { isCSS, adoptStyles } from './css.js';
|
|
4
5
|
import { register, tagOf } from './registry.js';
|
|
5
6
|
import { parse as deserializeProp } from './serialize.js';
|
|
@@ -118,6 +119,44 @@ function safeString(v) {
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Warn that a function value was dropped rather than reflected (#1169).
|
|
124
|
+
*
|
|
125
|
+
* A silently missing attribute is its own confusion, so say what went and why.
|
|
126
|
+
* The message deliberately does NOT include the value: printing the source is
|
|
127
|
+
* the leak this guard exists to prevent, and a server log is not always a
|
|
128
|
+
* private place.
|
|
129
|
+
*
|
|
130
|
+
* UNCONDITIONAL, matching the `.prop=${fn}` unserializable-value drop in
|
|
131
|
+
* `render-server.js`, which is the sibling path this guard mirrors. Two
|
|
132
|
+
* reasons not to gate it on a dev flag. It fires only on a genuine mistake (a
|
|
133
|
+
* function is never a meaningful attribute value), so there is no volume to
|
|
134
|
+
* suppress, and reflection runs per assignment rather than per frame.
|
|
135
|
+
*
|
|
136
|
+
* More decisively, a `NODE_ENV` gate does not survive the dist build.
|
|
137
|
+
* `scripts/build-framework-dist.js` runs esbuild with `platform: 'browser'`
|
|
138
|
+
* and `minify: true`, which substitutes `process.env.NODE_ENV` with
|
|
139
|
+
* `"production"` and folds the check to a constant. The SSR half of the
|
|
140
|
+
* warning would then be unreachable in every published build, which is how
|
|
141
|
+
* every installed app runs, and SSR is the half that matters most, since that
|
|
142
|
+
* is where the leaked source reached visitors.
|
|
143
|
+
*
|
|
144
|
+
* @param {{ constructor: unknown, tagName?: string }} host
|
|
145
|
+
* @param {string} propName
|
|
146
|
+
* @param {string} attrName
|
|
147
|
+
*/
|
|
148
|
+
function warnFunctionReflection(host, propName, attrName) {
|
|
149
|
+
if (typeof console === 'undefined' || !console.warn) return;
|
|
150
|
+
const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown';
|
|
151
|
+
console.warn(
|
|
152
|
+
`[webjs] reflect:true property "${propName}" on <${tag}> holds a function `
|
|
153
|
+
+ `(or an array carrying one), which has no HTML attribute representation. `
|
|
154
|
+
+ `Removing "${attrName}" instead of stringifying it (a stringified function `
|
|
155
|
+
+ `writes its source into the page, so a server action's body would ship to `
|
|
156
|
+
+ `the browser). Pass a string, or drop reflect on this property.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
121
160
|
/**
|
|
122
161
|
* A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
|
|
123
162
|
* while staying JSDoc-only and no-build.
|
|
@@ -685,13 +724,43 @@ class WebComponentBase extends Base {
|
|
|
685
724
|
const serialized = decl.converter.toAttribute(value, decl.type);
|
|
686
725
|
if (serialized == null) this.removeAttribute(attrName);
|
|
687
726
|
else this.setAttribute(attrName, serialized);
|
|
727
|
+
} else if (typeof value === 'function') {
|
|
728
|
+
// #1169: the value IS a function, and no branch below has a
|
|
729
|
+
// meaningful serialization for one. `String(fn)` is the function's
|
|
730
|
+
// SOURCE, so a reflected `'use server'` action would ship its whole
|
|
731
|
+
// body, closure secrets included, to every visitor, and
|
|
732
|
+
// `JSON.stringify(fn)` is `undefined`, which `setAttribute` writes as
|
|
733
|
+
// the literal string "undefined". Treat it like `null` and remove the
|
|
734
|
+
// attribute, matching what the `.prop=${fn}` SSR binding already does
|
|
735
|
+
// for an unserializable value.
|
|
736
|
+
//
|
|
737
|
+
// Placed AFTER the converter branch on purpose: a custom
|
|
738
|
+
// `toAttribute` is author-controlled, so its author has taken
|
|
739
|
+
// responsibility for serializing whatever they are handed.
|
|
740
|
+
this.removeAttribute(attrName);
|
|
741
|
+
warnFunctionReflection(this, propName, attrName);
|
|
688
742
|
} else if (decl.type === Boolean) {
|
|
689
743
|
if (value) this.setAttribute(attrName, '');
|
|
690
744
|
else this.removeAttribute(attrName);
|
|
691
745
|
} else if (value == null) {
|
|
692
746
|
this.removeAttribute(attrName);
|
|
693
747
|
} else if (decl.type === Object || decl.type === Array) {
|
|
748
|
+
// A JSON-typed prop CARRYING a function is safe and stays whole:
|
|
749
|
+
// `JSON.stringify` drops a function to `null` in an array and omits
|
|
750
|
+
// the key in an object, so `[1, 2, fn]` serializes to `[1,2,null]`
|
|
751
|
+
// with no source and no data loss. Refusing here would discard the
|
|
752
|
+
// `1` and the `2` on a path that never leaked.
|
|
694
753
|
this.setAttribute(attrName, JSON.stringify(value));
|
|
754
|
+
} else if (carriesFunction(value)) {
|
|
755
|
+
// The string fall-through is the one place a CARRIED function still
|
|
756
|
+
// leaks. `String([fn])` is `Array.prototype.join`, which runs
|
|
757
|
+
// `String()` on every element, so `[serverAction]` writes the same
|
|
758
|
+
// source `serverAction` does. Hence the recursive predicate the
|
|
759
|
+
// form-action guard already uses, rather than a bare `typeof`, but
|
|
760
|
+
// scoped to this branch rather than applied above, where JSON
|
|
761
|
+
// handles the same shape losslessly.
|
|
762
|
+
this.removeAttribute(attrName);
|
|
763
|
+
warnFunctionReflection(this, propName, attrName);
|
|
695
764
|
} else {
|
|
696
765
|
this.setAttribute(attrName, String(value));
|
|
697
766
|
}
|