@webjsdev/core 0.7.17 → 0.7.19
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 +16 -16
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +5 -5
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +10 -0
- package/index.d.ts +5 -0
- package/index.js +10 -0
- package/package.json +1 -1
- package/src/action-abort-client.d.ts +3 -0
- package/src/action-abort-client.js +39 -0
- package/src/action-cache-client.d.ts +6 -0
- package/src/action-cache-client.js +103 -0
- package/src/action-seed-client.d.ts +17 -0
- package/src/action-seed-client.js +107 -0
- package/src/component.js +13 -0
- package/src/router-client.js +10 -0
- package/src/serialize.js +32 -2
- package/src/webjs-config.d.ts +9 -0
package/index-browser.js
CHANGED
|
@@ -52,6 +52,16 @@ export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.
|
|
|
52
52
|
// Optimistic-mutation helper (thin signal wrapper, rolls back on failure)
|
|
53
53
|
export { optimistic } from './src/optimistic.js';
|
|
54
54
|
|
|
55
|
+
// SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
|
|
56
|
+
// first call so async-render hydration does not re-fetch the SSR'd data.
|
|
57
|
+
export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
|
|
58
|
+
// Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
|
|
59
|
+
// browser-cache eviction after a mutation. Inert server-side.
|
|
60
|
+
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
|
61
|
+
// Client action-abort plumbing (#492): a superseded async render aborts its
|
|
62
|
+
// in-flight action fetches. Inert server-side.
|
|
63
|
+
export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
|
|
64
|
+
|
|
55
65
|
// Directives, also available via '@webjsdev/core/directives'. The full
|
|
56
66
|
// lit-html-parity set is re-exported here so the dist browser bundle (which
|
|
57
67
|
// the `@webjsdev/core/directives` subpath collapses onto in dist mode) carries
|
package/index.d.ts
CHANGED
|
@@ -91,6 +91,11 @@ export { Task, TaskStatus } from './src/task.js';
|
|
|
91
91
|
// src/*.d.ts (#388 fixed the index.d.ts drift from index.js).
|
|
92
92
|
export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
|
|
93
93
|
export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
|
|
94
|
+
export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
|
|
95
|
+
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
|
96
|
+
// Client action-abort plumbing (#492): a superseded async render aborts its
|
|
97
|
+
// in-flight action fetches. Inert server-side.
|
|
98
|
+
export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
|
|
94
99
|
export { stringify, parse, serialize, deserialize } from './src/serialize.js';
|
|
95
100
|
export { WebjsFrame } from './src/webjs-frame.js';
|
|
96
101
|
export { WebjsStream, renderStream } from './src/webjs-stream.js';
|
package/index.js
CHANGED
|
@@ -34,6 +34,16 @@ export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.
|
|
|
34
34
|
// Optimistic-mutation helper (thin signal wrapper, rolls back on failure)
|
|
35
35
|
export { optimistic } from './src/optimistic.js';
|
|
36
36
|
|
|
37
|
+
// SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
|
|
38
|
+
// 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';
|
|
40
|
+
// Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
|
|
41
|
+
// browser-cache eviction after a mutation. Inert server-side.
|
|
42
|
+
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
|
43
|
+
// Client action-abort plumbing (#492): a superseded async render aborts its
|
|
44
|
+
// in-flight action fetches. Inert server-side.
|
|
45
|
+
export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
|
|
46
|
+
|
|
37
47
|
// Directives, also available via '@webjsdev/core/directives'. The full
|
|
38
48
|
// lit-html-parity set is re-exported so the bare specifier exposes the same
|
|
39
49
|
// directive surface in Node as the browser bundle does.
|
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side action abort plumbing (#492).
|
|
3
|
+
*
|
|
4
|
+
* An `async render()` that supersedes an in-flight one should ABORT the previous
|
|
5
|
+
* render's action fetches, not just drop their results. The component sets a
|
|
6
|
+
* fresh AbortController's signal as the "active" signal right before each render
|
|
7
|
+
* (`setActiveActionSignal`), and the generated RPC stub binds every `fetch` to
|
|
8
|
+
* `activeActionSignal()`. When the next render supersedes, the component aborts
|
|
9
|
+
* the previous controller, cancelling those fetches.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort by design: the stub reads the active signal SYNCHRONOUSLY when the
|
|
12
|
+
* action is called, so it binds for an action invoked in the synchronous portion
|
|
13
|
+
* of `render()` (the common `const u = await getUser(id)` first statement). An
|
|
14
|
+
* action invoked after a later `await` may see no active signal; it then simply
|
|
15
|
+
* runs to completion and its result is dropped by the existing render-token
|
|
16
|
+
* guard, exactly as before this feature. Inert server-side (never set there).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The AbortSignal the currently-rendering component bound, or null. */
|
|
20
|
+
let active = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set (or clear with null) the signal the generated RPC stub binds its fetches
|
|
24
|
+
* to. The component sets this around the synchronous portion of `render()`.
|
|
25
|
+
* @param {AbortSignal | null} signal
|
|
26
|
+
*/
|
|
27
|
+
export function setActiveActionSignal(signal) {
|
|
28
|
+
active = signal || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The active abort signal a stub should bind a fetch to, or undefined when no
|
|
33
|
+
* render is in flight (an action called from an event handler is not bound, so
|
|
34
|
+
* a re-render never aborts a user-triggered mutation).
|
|
35
|
+
* @returns {AbortSignal | undefined}
|
|
36
|
+
*/
|
|
37
|
+
export function activeActionSignal() {
|
|
38
|
+
return active || undefined;
|
|
39
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Client tag-cache coordinator for HTTP-verb server actions (#488). Inert server-side. */
|
|
2
|
+
export function markStale(tags: string[]): void;
|
|
3
|
+
export function fetchMark(): number;
|
|
4
|
+
export function registerKeyTags(key: string, tags: string[], since?: number): void;
|
|
5
|
+
export function consumeStale(key: string): boolean;
|
|
6
|
+
export function parseTagHeader(value: string | null): string[];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side tag cache coordinator for HTTP-verb server actions (#488).
|
|
3
|
+
*
|
|
4
|
+
* A GET action is cached by the BROWSER HTTP cache (its `Cache-Control` +
|
|
5
|
+
* ETag). After a mutation, that cached entry would still be served within its
|
|
6
|
+
* `max-age`, so a tag-based eviction is layered on top: the server tells the
|
|
7
|
+
* client which tags a GET belongs to (the `X-Webjs-Tags` response header) and
|
|
8
|
+
* which tags a mutation invalidated (`X-Webjs-Invalidate`). When a later GET's
|
|
9
|
+
* tags were invalidated AFTER that key's last fetch STARTED, the stub re-fetches
|
|
10
|
+
* with `cache: 'no-cache'` (a conditional revalidation that bypasses the stale
|
|
11
|
+
* browser-cache entry and returns fresh data, since the ETag changed).
|
|
12
|
+
*
|
|
13
|
+
* Mechanism: a monotonic CLOCK, bumped on every invalidation. Each tag records
|
|
14
|
+
* the clock at its last invalidation; each GET records the clock value SAMPLED
|
|
15
|
+
* JUST BEFORE its fetch was dispatched. A read needs a bypass when any of its
|
|
16
|
+
* tags was invalidated at a clock value greater than the key's sampled value.
|
|
17
|
+
* Sampling before the fetch (not when the response lands) is what closes the
|
|
18
|
+
* race where a mutation commits while a read is in flight: such a mutation has
|
|
19
|
+
* a higher clock than the read's sample, so the next read still bypasses.
|
|
20
|
+
*
|
|
21
|
+
* A thin coordinator, NOT a data store (the data lives in the browser HTTP
|
|
22
|
+
* cache, on-thesis: no bespoke client query cache). Inert server-side.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Monotonic logical clock; bumped once per `markStale` call. */
|
|
26
|
+
let clock = 0;
|
|
27
|
+
|
|
28
|
+
/** `tag -> clock value at its last invalidation`. */
|
|
29
|
+
const tagInvalidatedAt = new Map();
|
|
30
|
+
|
|
31
|
+
/** `key -> { tags, since }` where `since` is the clock sampled before its fetch. */
|
|
32
|
+
const keyInfo = new Map();
|
|
33
|
+
|
|
34
|
+
/** Bound `keyInfo` growth for a long-lived session (FIFO-evict the oldest). */
|
|
35
|
+
const MAX_KEYS = 1000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mark tags invalidated (called after a mutation reports `X-Webjs-Invalidate`):
|
|
39
|
+
* advance the clock and stamp each tag with it.
|
|
40
|
+
* @param {string[]} tags
|
|
41
|
+
*/
|
|
42
|
+
export function markStale(tags) {
|
|
43
|
+
if (!Array.isArray(tags) || !tags.length) return;
|
|
44
|
+
clock += 1;
|
|
45
|
+
for (const t of tags) if (t) tagInvalidatedAt.set(t, clock);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sample the clock just before dispatching a GET fetch. The value is passed to
|
|
50
|
+
* `registerKeyTags` so an invalidation that lands WHILE the fetch is in flight
|
|
51
|
+
* (a higher clock than this sample) is detected on the next read.
|
|
52
|
+
* @returns {number}
|
|
53
|
+
*/
|
|
54
|
+
export function fetchMark() {
|
|
55
|
+
return clock;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Record the tags a GET response declared for its key, stamped with the clock
|
|
60
|
+
* value sampled before the fetch (`since`).
|
|
61
|
+
* @param {string} key the GET cache key (`stringify(args)`)
|
|
62
|
+
* @param {string[]} tags
|
|
63
|
+
* @param {number} [since] the `fetchMark()` sampled before the fetch
|
|
64
|
+
*/
|
|
65
|
+
export function registerKeyTags(key, tags, since) {
|
|
66
|
+
if (!Array.isArray(tags) || !tags.length) return;
|
|
67
|
+
// Re-insert to move the key to the newest position (LRU-ish), then cap.
|
|
68
|
+
keyInfo.delete(key);
|
|
69
|
+
keyInfo.set(key, { tags: tags.slice(), since: typeof since === 'number' ? since : clock });
|
|
70
|
+
if (keyInfo.size > MAX_KEYS) {
|
|
71
|
+
const oldest = keyInfo.keys().next().value;
|
|
72
|
+
if (oldest !== undefined) keyInfo.delete(oldest);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a GET for `key` should bypass the browser cache (re-fetch with
|
|
78
|
+
* `cache: 'no-cache'`) because one of its tags was invalidated AFTER this key's
|
|
79
|
+
* last fetch started. An unrecorded key (never fetched, e.g. resolved from the
|
|
80
|
+
* SSR seed) returns false: its browser-cache entry does not exist yet, so a
|
|
81
|
+
* normal fetch already returns fresh data.
|
|
82
|
+
* @param {string} key
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
export function consumeStale(key) {
|
|
86
|
+
const info = keyInfo.get(key);
|
|
87
|
+
if (!info) return false;
|
|
88
|
+
for (const t of info.tags) if ((tagInvalidatedAt.get(t) || 0) > info.since) return true;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parse a comma-separated tag header into a trimmed, non-empty list. */
|
|
93
|
+
export function parseTagHeader(value) {
|
|
94
|
+
if (!value) return [];
|
|
95
|
+
return value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Test seam: clear all tag state. */
|
|
99
|
+
export function __resetActionCache() {
|
|
100
|
+
clock = 0;
|
|
101
|
+
tagInvalidatedAt.clear();
|
|
102
|
+
keyInfo.clear();
|
|
103
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Client-side SSR action-seed consumer (#472). Inert server-side. */
|
|
2
|
+
|
|
3
|
+
/** Returned by `takeSeed` when no seed matches; distinct from any real value. */
|
|
4
|
+
export const SEED_MISS: unique symbol;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merge any seeds found under `root` (or the whole document) into the global
|
|
8
|
+
* consume-once store, removing the carriers. Reads the page-level
|
|
9
|
+
* `#__webjs-seeds` JSON block and per-element `[data-webjs-seed]` carriers.
|
|
10
|
+
*/
|
|
11
|
+
export function scanSeeds(root?: ParentNode): void;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Look up and CONSUME the seed for an action call, or return `SEED_MISS`.
|
|
15
|
+
* Keyed `hash/fn/argsKey`; the first call lazily scans the initial document.
|
|
16
|
+
*/
|
|
17
|
+
export function takeSeed(hash: string, fnName: string, argsKey: string): unknown;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side SSR action-seed consumer (#472).
|
|
3
|
+
*
|
|
4
|
+
* The server (`@webjsdev/server`'s `action-seed.js`) serializes each action
|
|
5
|
+
* result invoked during SSR into a `<script type="application/json"
|
|
6
|
+
* id="__webjs-seeds">` block (and, for streamed regions, per-element
|
|
7
|
+
* `data-webjs-seed` attributes). On the client, the generated RPC stub
|
|
8
|
+
* (`actions.js`) calls `takeSeed(hash, fn, argsKey)` on its FIRST invocation: a
|
|
9
|
+
* hit resolves the action synchronously with the SSR value (no RPC, no flicker
|
|
10
|
+
* on hydration); a miss falls through to the normal RPC.
|
|
11
|
+
*
|
|
12
|
+
* Consume-once: a hit removes the seed, so a later refetch / arg-change always
|
|
13
|
+
* goes to the network (the seed is a first-paint optimization, not a cache).
|
|
14
|
+
* Keyed `hash/fn/stringify(args)`, where `hash` and `stringify` match exactly
|
|
15
|
+
* what the server emitted, so distinct components and distinct args each map to
|
|
16
|
+
* their own seed. A miss is always safe (it just re-fetches).
|
|
17
|
+
*
|
|
18
|
+
* This module is imported by the generated stub via the bare `@webjsdev/core`
|
|
19
|
+
* specifier; on the server it is inert (its only DOM access is inside
|
|
20
|
+
* `scanSeeds`, never called server-side).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { parse } from './serialize.js';
|
|
24
|
+
|
|
25
|
+
/** Global consume-once seed store: `key -> value`. */
|
|
26
|
+
const seeds = new Map();
|
|
27
|
+
|
|
28
|
+
/** Returned by `takeSeed` when no seed matches; distinct from any real value. */
|
|
29
|
+
export const SEED_MISS = Symbol('webjs.seed.miss');
|
|
30
|
+
|
|
31
|
+
/** One-time eager scan guard (the initial-load document is scanned lazily). */
|
|
32
|
+
let scannedInitial = false;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Merge any seeds found under `root` into the global store, then remove the
|
|
36
|
+
* carriers so a re-scan (a streamed boundary, a soft navigation) never
|
|
37
|
+
* re-ingests stale data. Reads both the page-level `#__webjs-seeds` JSON block
|
|
38
|
+
* and per-element `[data-webjs-seed]` carriers. Idempotent and fail-open: a
|
|
39
|
+
* malformed payload is skipped, never thrown.
|
|
40
|
+
* @param {ParentNode & { querySelectorAll?: Function }} [root]
|
|
41
|
+
*/
|
|
42
|
+
export function scanSeeds(root) {
|
|
43
|
+
const scope = root || (typeof document !== 'undefined' ? document : null);
|
|
44
|
+
if (!scope || typeof scope.querySelectorAll !== 'function') return;
|
|
45
|
+
// Page-level JSON block(s).
|
|
46
|
+
for (const el of scope.querySelectorAll('script[type="application/json"]#__webjs-seeds, script[type="application/json"][data-webjs-seeds]')) {
|
|
47
|
+
ingest(el.textContent, el);
|
|
48
|
+
}
|
|
49
|
+
// Per-element carriers (streamed boundaries / future per-component seeding).
|
|
50
|
+
for (const el of scope.querySelectorAll('[data-webjs-seed]')) {
|
|
51
|
+
ingest(el.getAttribute('data-webjs-seed'), el, () => el.removeAttribute('data-webjs-seed'));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse one serialized seed payload and merge it (first write wins, so an
|
|
57
|
+
* already-consumed or earlier seed is never clobbered by a later duplicate),
|
|
58
|
+
* then run `cleanup` (or remove the element).
|
|
59
|
+
* @param {string | null} raw
|
|
60
|
+
* @param {Element} el
|
|
61
|
+
* @param {() => void} [cleanup]
|
|
62
|
+
*/
|
|
63
|
+
function ingest(raw, el, cleanup) {
|
|
64
|
+
if (raw) {
|
|
65
|
+
try {
|
|
66
|
+
const obj = parse(raw);
|
|
67
|
+
if (obj && typeof obj === 'object') {
|
|
68
|
+
for (const k in obj) if (!seeds.has(k)) seeds.set(k, obj[k]);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Malformed payload: ignore, the stub re-fetches.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (cleanup) cleanup();
|
|
75
|
+
else el.remove?.();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Look up and CONSUME the seed for an action call. Returns the seeded value
|
|
80
|
+
* (removing it) on a hit, or `SEED_MISS` when there is none. The first call
|
|
81
|
+
* lazily scans the initial document, so the boot path needs no wiring; the
|
|
82
|
+
* router calls `scanSeeds(subtree)` for content that arrives later.
|
|
83
|
+
* @param {string} hash the action file's hash (the RPC endpoint hash)
|
|
84
|
+
* @param {string} fnName the exported action name
|
|
85
|
+
* @param {string} argsKey `stringify(args)`, computed by the stub with the same
|
|
86
|
+
* serializer the server used, so identical args produce an identical key
|
|
87
|
+
* @returns {unknown | typeof SEED_MISS}
|
|
88
|
+
*/
|
|
89
|
+
export function takeSeed(hash, fnName, argsKey) {
|
|
90
|
+
if (!scannedInitial) {
|
|
91
|
+
scannedInitial = true;
|
|
92
|
+
scanSeeds();
|
|
93
|
+
}
|
|
94
|
+
const key = `${hash}/${fnName}/${argsKey}`;
|
|
95
|
+
if (seeds.has(key)) {
|
|
96
|
+
const v = seeds.get(key);
|
|
97
|
+
seeds.delete(key);
|
|
98
|
+
return v;
|
|
99
|
+
}
|
|
100
|
+
return SEED_MISS;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Test seam: drop all seeds and reset the lazy-scan guard. */
|
|
104
|
+
export function __resetSeeds() {
|
|
105
|
+
seeds.clear();
|
|
106
|
+
scannedInitial = false;
|
|
107
|
+
}
|
package/src/component.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { render as clientRender } from './render-client.js';
|
|
2
|
+
import { setActiveActionSignal } from './action-abort-client.js';
|
|
2
3
|
import { isCSS, adoptStyles } from './css.js';
|
|
3
4
|
import { register, tagOf } from './registry.js';
|
|
4
5
|
import { parse as deserializeProp } from './serialize.js';
|
|
@@ -1015,11 +1016,23 @@ export class WebComponent extends Base {
|
|
|
1015
1016
|
// by-sync case (#469); a shouldUpdate=false cycle never reaches here,
|
|
1016
1017
|
// so it does not invalidate an in-flight async render.
|
|
1017
1018
|
this.__renderToken = (this.__renderToken || 0) + 1;
|
|
1019
|
+
// Abort the superseded render's in-flight action fetches (#492), start a
|
|
1020
|
+
// fresh controller for this render, and bind it as the active signal so
|
|
1021
|
+
// the RPC stub ties its fetches to this render. A superseded fetch's
|
|
1022
|
+
// AbortError is dropped by the render-token guard in _commitAsync.
|
|
1023
|
+
if (this.__renderAbort) this.__renderAbort.abort();
|
|
1024
|
+
this.__renderAbort = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
1025
|
+
setActiveActionSignal(this.__renderAbort ? this.__renderAbort.signal : null);
|
|
1018
1026
|
try {
|
|
1019
1027
|
const r = this.update(changedProperties);
|
|
1020
1028
|
if (r && typeof r.then === 'function') pendingCommit = r;
|
|
1021
1029
|
} catch (error) {
|
|
1022
1030
|
this._handleRenderError(/** @type {Error} */ (error));
|
|
1031
|
+
} finally {
|
|
1032
|
+
// The synchronous portion of render() has run; actions invoked there
|
|
1033
|
+
// already captured the signal. Clear it so a later event handler is
|
|
1034
|
+
// not bound to a stale render's controller.
|
|
1035
|
+
setActiveActionSignal(null);
|
|
1023
1036
|
}
|
|
1024
1037
|
|
|
1025
1038
|
if (!pendingCommit) {
|
package/src/router-client.js
CHANGED
|
@@ -11,6 +11,9 @@ import { renderStream } from './webjs-stream.js';
|
|
|
11
11
|
// Register <webjs-suspense> (the element-level streaming boundary, #471) so it
|
|
12
12
|
// is layout-neutral and available for the progressive soft-nav streaming apply.
|
|
13
13
|
import './webjs-suspense.js';
|
|
14
|
+
// Ingest SSR action seeds (#472) from an incoming soft-nav document before its
|
|
15
|
+
// components hydrate, so a navigated async component resolves from the seed.
|
|
16
|
+
import { scanSeeds } from './action-seed-client.js';
|
|
14
17
|
|
|
15
18
|
/** The content type a content-negotiated stream-action response carries (#248). */
|
|
16
19
|
const STREAM_MIME = 'text/vnd.webjs-stream.html';
|
|
@@ -2067,6 +2070,13 @@ function upgradeCustomElementsInRange(range) {
|
|
|
2067
2070
|
}
|
|
2068
2071
|
|
|
2069
2072
|
function applySwap(doc, frameId, revalidating, href, incomingBuild) {
|
|
2073
|
+
// SSR action seeding (#472): ingest any seed payload the incoming page
|
|
2074
|
+
// carries BEFORE its components are grafted into the live DOM and upgrade, so
|
|
2075
|
+
// a soft-navigated async component resolves from the seed instead of
|
|
2076
|
+
// re-fetching. Scanning `doc` (the detached parse) also strips the seed
|
|
2077
|
+
// carriers, so the inert payload never lands in the live document.
|
|
2078
|
+
try { scanSeeds(doc); } catch { /* seeding is best-effort */ }
|
|
2079
|
+
|
|
2070
2080
|
// Any clean swap (no importmap mismatch, including cache restores
|
|
2071
2081
|
// and frame swaps where we don't even run the mismatch check) is a
|
|
2072
2082
|
// signal that the user successfully navigated, so clear the reload
|
package/src/serialize.js
CHANGED
|
@@ -51,6 +51,36 @@
|
|
|
51
51
|
const TAG = '_$wj';
|
|
52
52
|
const ID_KEY = '_id';
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Keys that, assigned with bracket notation, mutate the target instead of
|
|
56
|
+
* adding an own data property: `out['__proto__'] = x` invokes the prototype
|
|
57
|
+
* setter (a prototype-pollution vector on decode, reachable because
|
|
58
|
+
* `JSON.parse` makes a wire `"__proto__"` an OWN key; and on encode it silently
|
|
59
|
+
* DROPS a legitimate own `__proto__` data property and corrupts the
|
|
60
|
+
* accumulator's prototype). `constructor` / `prototype` shadow in ways
|
|
61
|
+
* downstream code can be confused by.
|
|
62
|
+
*/
|
|
63
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Assign `value` onto a plain accumulator under `key`. A normal key is a plain
|
|
67
|
+
* assignment (the hot path); an unsafe key is written as an ordinary own
|
|
68
|
+
* enumerable data property via `defineProperty`, so the value survives as data
|
|
69
|
+
* (round-trippable, since JSON includes an own enumerable `__proto__`) and the
|
|
70
|
+
* prototype chain is never touched. Used on BOTH the encode and decode object
|
|
71
|
+
* loops so `serialize` / `deserialize` stay inverses on these keys.
|
|
72
|
+
* @param {Record<string, unknown>} out
|
|
73
|
+
* @param {string} key
|
|
74
|
+
* @param {unknown} value
|
|
75
|
+
*/
|
|
76
|
+
function safeAssign(out, key, value) {
|
|
77
|
+
if (UNSAFE_KEYS.has(key)) {
|
|
78
|
+
Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true });
|
|
79
|
+
} else {
|
|
80
|
+
out[key] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
54
84
|
/* ----------------------------------------------------------------- *
|
|
55
85
|
* Public API *
|
|
56
86
|
* ----------------------------------------------------------------- */
|
|
@@ -225,7 +255,7 @@ function encodeOne(v, ctx) {
|
|
|
225
255
|
for (const k in v) {
|
|
226
256
|
if (!Object.prototype.hasOwnProperty.call(v, k)) continue;
|
|
227
257
|
const escaped = isReservedKey(k) ? '_' + k : k;
|
|
228
|
-
out
|
|
258
|
+
safeAssign(out, escaped, encodeOne(v[k], ctx));
|
|
229
259
|
}
|
|
230
260
|
}
|
|
231
261
|
|
|
@@ -314,7 +344,7 @@ function decode(v, ctx) {
|
|
|
314
344
|
if (!Object.prototype.hasOwnProperty.call(v, k)) continue;
|
|
315
345
|
if (k === ID_KEY) continue;
|
|
316
346
|
const realKey = isEscapedReservedKey(k) ? k.slice(1) : k;
|
|
317
|
-
out
|
|
347
|
+
safeAssign(out, realKey, decode(v[k], ctx));
|
|
318
348
|
}
|
|
319
349
|
return out;
|
|
320
350
|
}
|
package/src/webjs-config.d.ts
CHANGED
|
@@ -109,6 +109,15 @@ export interface WebjsConfig {
|
|
|
109
109
|
*/
|
|
110
110
|
elide?: boolean;
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* SSR action-result seeding switch (#472). Default `true`. When on, a
|
|
114
|
+
* `'use server'` action's SSR result is serialized into the page so an
|
|
115
|
+
* `async render()` component does not re-fetch it over RPC on hydration. Set
|
|
116
|
+
* to `false` to disable (the client re-fetches as before). The `WEBJS_SEED`
|
|
117
|
+
* env override wins over this.
|
|
118
|
+
*/
|
|
119
|
+
seed?: boolean;
|
|
120
|
+
|
|
112
121
|
/** Per-path response-header rules, shaped like Next's. */
|
|
113
122
|
headers?: WebjsHeaderRule[];
|
|
114
123
|
|