@webjsdev/core 0.8.0 → 0.9.1
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/README.md +5 -0
- package/dist/webjs-core-browser.js +6 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -19
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +14 -9
- package/index.d.ts +53 -10
- package/index.js +17 -9
- package/package.json +15 -2
- package/src/action-effects-client.d.ts +5 -0
- package/src/action-effects-client.js +84 -0
- package/src/action-seed-client.js +5 -5
- package/src/asset-url.d.ts +18 -0
- package/src/asset-url.js +12 -13
- package/src/async-directive.d.ts +18 -0
- package/src/async-directive.js +88 -0
- package/src/attribute-reader.js +197 -23
- package/src/binding-prefixes.js +0 -12
- package/src/component/lifecycle.js +760 -209
- package/src/component/reactive.js +2 -10
- package/src/component/server-element.js +23 -1
- package/src/component.d.ts +151 -41
- package/src/context.d.ts +94 -18
- package/src/context.js +341 -217
- package/src/converters.d.ts +7 -0
- package/src/converters.js +38 -0
- package/src/csp-nonce.d.ts +18 -0
- package/src/css.d.ts +32 -6
- package/src/css.js +178 -14
- package/src/directive.d.ts +58 -0
- package/src/directive.js +119 -0
- package/src/directives.d.ts +38 -1
- package/src/directives.js +158 -1
- package/src/form-action.d.ts +13 -0
- package/src/form-action.js +139 -4
- package/src/hot.js +23 -0
- package/src/html.d.ts +4 -0
- package/src/html.js +35 -3
- package/src/is-server.d.ts +2 -0
- package/src/is-server.js +18 -0
- package/src/jsx-guard.js +130 -0
- package/src/metadata.d.ts +22 -3
- package/src/nav.d.ts +4 -1
- package/src/nav.js +44 -11
- package/src/optimistic.js +2 -2
- package/src/refresh-header.js +52 -0
- package/src/registry.js +10 -1
- package/src/render-client/adopt-slots.js +8 -3
- package/src/render-client/parts.js +745 -31
- package/src/render-client/reconciler.js +56 -11
- package/src/render-client/template-compiler.js +139 -44
- package/src/render-client.d.ts +5 -1
- package/src/render-server/attrs.js +2 -2
- package/src/render-server/compile.js +34 -0
- package/src/render-server/errors.js +37 -0
- package/src/render-server/expand.js +61 -8
- package/src/render-server/served-attrs.js +1 -2
- package/src/render-server/walk.js +135 -3
- package/src/render-server.d.ts +3 -0
- package/src/render-server.js +2 -0
- package/src/repeat.d.ts +7 -2
- package/src/repeat.js +20 -6
- package/src/router-client/dom-differ.js +17 -0
- package/src/router-client/fetch-apply.js +86 -5
- package/src/router-client/navigator.js +45 -9
- package/src/router-client/prefetch.js +3 -4
- package/src/router-client/root-attrs.js +43 -0
- package/src/router-client/stream.js +7 -0
- package/src/router-client/swap.js +4 -1
- package/src/routes.d.ts +75 -18
- package/src/serialize.d.ts +2 -0
- package/src/serialize.js +35 -4
- package/src/signal.d.ts +7 -0
- package/src/signal.js +26 -6
- package/src/slot/polyfills.js +3 -3
- package/src/slot.js +4 -8
- package/src/static-html.d.ts +22 -0
- package/src/static-html.js +144 -0
- package/src/task.d.ts +63 -20
- package/src/task.js +326 -293
- package/src/testing.d.ts +42 -0
- package/src/webjs-config.d.ts +1 -8
- package/src/websocket-client.d.ts +24 -0
|
@@ -154,7 +154,9 @@ export function enableClientRouter() {
|
|
|
154
154
|
// never calls this, and a page that ships no component never loads
|
|
155
155
|
// @webjsdev/core at all, so both no-router cases resolve to a full reload
|
|
156
156
|
// without either side assuming anything about the other.
|
|
157
|
-
|
|
157
|
+
// It publishes the OUTCOME form (`refreshOutcome`), so the client can tell
|
|
158
|
+
// a refresh that applied from one that kept the page under a dev error.
|
|
159
|
+
/** @type any */ (globalThis).__webjsRefreshPage = refreshOutcome;
|
|
158
160
|
// Both `click` and `submit` are BUBBLE phase, not capture. A component's
|
|
159
161
|
// per-element `@click` / `@submit` handler (render-client.js) runs in the
|
|
160
162
|
// at-target phase, BEFORE a document-level bubble listener. So onClick /
|
|
@@ -446,7 +448,24 @@ export function revalidate(url) {
|
|
|
446
448
|
* caller should fall back to a full load.
|
|
447
449
|
*/
|
|
448
450
|
export async function refreshPage(mode) {
|
|
449
|
-
|
|
451
|
+
return (await refreshOutcome(mode)) !== 'failed';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* What `refreshPage` did, for the dev live-reload client (#1689), which reads
|
|
456
|
+
* it through `globalThis.__webjsRefreshPage`: `'applied'` swapped the new
|
|
457
|
+
* render in, `'settled'` found the page already resolved (a hard navigation,
|
|
458
|
+
* a snapshot restore, or a newer navigation that owns it), `'dev-error'` kept
|
|
459
|
+
* the last good page because the dev server answered with a render error, and
|
|
460
|
+
* `'failed'` applied nothing, so the caller falls back to a full load. The dev
|
|
461
|
+
* client logs an in-place update only for `'applied'`, so the console never
|
|
462
|
+
* reports success for an edit that broke the page.
|
|
463
|
+
*
|
|
464
|
+
* @param {'page' | 'shell'} [mode]
|
|
465
|
+
* @returns {Promise<'applied' | 'settled' | 'dev-error' | 'failed'>}
|
|
466
|
+
*/
|
|
467
|
+
async function refreshOutcome(mode) {
|
|
468
|
+
if (!enabled || typeof location === 'undefined') return 'failed';
|
|
450
469
|
// Every cached copy predates the change, so drop both caches before fetching.
|
|
451
470
|
revalidate();
|
|
452
471
|
// And the refusal memos (#1407), which `revalidate` deliberately leaves alone
|
|
@@ -456,9 +475,8 @@ export async function refreshPage(mode) {
|
|
|
456
475
|
// browser does NOT download: a page gaining or losing a `Suspense(...)`
|
|
457
476
|
// boundary is the plain case, since rendering one does not by itself make the
|
|
458
477
|
// page client-effecting, so it classifies `page` rather than a reload. An edit
|
|
459
|
-
// to anything that SHIPS reloads instead and never arrives,
|
|
460
|
-
// `loading.{js,ts}
|
|
461
|
-
// shipped, and a brand-new one is in no graph at all).
|
|
478
|
+
// to anything that SHIPS reloads instead and never arrives, and so does a
|
|
479
|
+
// brand-new `loading.{js,ts}`, which is in no graph at all.
|
|
462
480
|
clearPrefetchRefused();
|
|
463
481
|
try {
|
|
464
482
|
const res = await performNavigation(location.href, false, null, { refresh: mode === 'shell' ? 'shell' : 'page' });
|
|
@@ -482,10 +500,15 @@ export async function refreshPage(mode) {
|
|
|
482
500
|
// superseded this one and owns the page now, and reloading would yank the
|
|
483
501
|
// reader out of it; Turbo drops a refresh that lands mid-navigation for the
|
|
484
502
|
// same reason.
|
|
485
|
-
|
|
486
|
-
|
|
503
|
+
//
|
|
504
|
+
// And a `devError` is not one (#1689): the dev server rendered an error for
|
|
505
|
+
// this page and the overlay shows it. The page stays as it was under the
|
|
506
|
+
// overlay, and the edit that fixes it refreshes in place.
|
|
507
|
+
if (!res || res.aborted) return 'settled';
|
|
508
|
+
if (res.devError) return 'dev-error';
|
|
509
|
+
return res.applied ? 'applied' : 'failed';
|
|
487
510
|
} catch (_) {
|
|
488
|
-
return
|
|
511
|
+
return 'failed';
|
|
489
512
|
}
|
|
490
513
|
}
|
|
491
514
|
|
|
@@ -864,7 +887,12 @@ export async function performSubmission(href, method, body, frameId, form, opts)
|
|
|
864
887
|
navigatingFlagTimer = null;
|
|
865
888
|
}, 150);
|
|
866
889
|
|
|
867
|
-
|
|
890
|
+
// A write keeps the page it was submitted from on screen while it runs, as a
|
|
891
|
+
// Next.js server-action form does (#1686): `loading.ts` is a navigation
|
|
892
|
+
// skeleton, and showing it for a write detached the very form being
|
|
893
|
+
// submitted, so a validation re-render lost what was typed. A safe-method
|
|
894
|
+
// submission (a search) is a navigation and keeps the skeleton.
|
|
895
|
+
const optimisticState = isSafe ? applyOptimisticLoading() : null;
|
|
868
896
|
|
|
869
897
|
// Submission-state lifecycle: mark the form busy + announce the start, then
|
|
870
898
|
// clear + announce the settle in the finally so EVERY exit (success,
|
|
@@ -900,6 +928,14 @@ export async function performSubmission(href, method, body, frameId, form, opts)
|
|
|
900
928
|
await _swapCommit;
|
|
901
929
|
if (myToken === currentNavigationToken) restoreFormState(typed);
|
|
902
930
|
}
|
|
931
|
+
// A successful write that stayed on this page resets the form, as React
|
|
932
|
+
// does for a form whose action succeeded (#1686), so the next entry starts
|
|
933
|
+
// from the defaults the server just rendered. A redirect elsewhere leaves
|
|
934
|
+
// the page and needs nothing.
|
|
935
|
+
if (!isSafe && form && outcome && outcome.ok && outcome.applied && outcome.samePage && myToken === currentNavigationToken) {
|
|
936
|
+
await _swapCommit;
|
|
937
|
+
if (myToken === currentNavigationToken && form.isConnected && typeof form.reset === 'function') form.reset();
|
|
938
|
+
}
|
|
903
939
|
// Mutating submissions invalidate cached versions of other URLs -
|
|
904
940
|
// do this *after* the response applies so the new page itself is
|
|
905
941
|
// snapshotted on the next nav, not pre-emptively wiped. Clear the
|
|
@@ -92,10 +92,9 @@ function prefetchRefusedRecently(key) {
|
|
|
92
92
|
* dev live-reload path, where an edit can add or remove a `Suspense` boundary
|
|
93
93
|
* and so start or stop the route streaming. Only an edit to a file the browser
|
|
94
94
|
* does NOT download arrives there; anything that SHIPS takes a full reload
|
|
95
|
-
* instead. A `loading.{js,ts}`
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* set the previous build produced.
|
|
95
|
+
* instead. A `loading.{js,ts}` is a route module, so editing one arrives here
|
|
96
|
+
* like a page edit (it never ships, #1686), while ADDING one reloads because a
|
|
97
|
+
* brand-new file is in no set the previous build produced.
|
|
99
98
|
*
|
|
100
99
|
* NEITHER form of `revalidate` clears them, which is a cost/benefit call rather
|
|
101
100
|
* than an impossibility. It is the post-MUTATION api an app calls after an RPC
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<html>` and `<body>` attributes follow the server across a soft navigation
|
|
3
|
+
* (#1686). The root layout owns them (`<html class=${theme}>`, `<body
|
|
4
|
+
* class=...>`), and a Next.js developer expects a changed theme cookie to
|
|
5
|
+
* show once the action or navigation lands, as it does when the root layout
|
|
6
|
+
* re-renders there. The swap replaces content inside `<body>`, so without
|
|
7
|
+
* this the attributes stayed as the first load left them until a reload.
|
|
8
|
+
*
|
|
9
|
+
* Only attributes the SERVER rendered are touched: the server lists their
|
|
10
|
+
* names in `data-wj-html` / `data-wj-body`, so an attribute a script sets in
|
|
11
|
+
* the browser (a theme bootstrap writing `data-theme` from localStorage) is
|
|
12
|
+
* never removed. A response that did not render the root layout (a reduced
|
|
13
|
+
* `X-Webjs-Have` fragment) carries no list and changes nothing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const MARKS = /** @type {const} */ ([['documentElement', 'data-wj-html'], ['body', 'data-wj-body']]);
|
|
17
|
+
|
|
18
|
+
/** @param {string | null} v */
|
|
19
|
+
const names = (v) => (v || '').split(/\s+/).filter(Boolean);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bring the live `<html>` and `<body>` attributes in line with an incoming
|
|
23
|
+
* page's, for the attributes its server rendered.
|
|
24
|
+
* @param {Document} doc the parsed incoming page
|
|
25
|
+
*/
|
|
26
|
+
export function syncRootAttributes(doc) {
|
|
27
|
+
if (typeof document === 'undefined' || !doc) return;
|
|
28
|
+
for (const [key, mark] of MARKS) {
|
|
29
|
+
const incoming = /** @type {Element | null} */ (/** @type any */ (doc)[key]);
|
|
30
|
+
const live = /** @type {Element | null} */ (/** @type any */ (document)[key]);
|
|
31
|
+
if (!incoming || !live || !incoming.hasAttribute(mark)) continue;
|
|
32
|
+
const next = names(incoming.getAttribute(mark));
|
|
33
|
+
for (const n of names(live.getAttribute(mark))) {
|
|
34
|
+
if (!next.includes(n)) live.removeAttribute(n);
|
|
35
|
+
}
|
|
36
|
+
for (const n of next) {
|
|
37
|
+
const v = incoming.getAttribute(n);
|
|
38
|
+
if (v === null) live.removeAttribute(n);
|
|
39
|
+
else if (live.getAttribute(n) !== v) live.setAttribute(n, v);
|
|
40
|
+
}
|
|
41
|
+
live.setAttribute(mark, next.join(' '));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { warnDropped } from './diagnostics.js';
|
|
10
10
|
import { upgradeTree } from './upgrade.js';
|
|
11
|
+
import { navigate } from './navigator.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Forward streamed Suspense resolver templates from the fetched body to
|
|
@@ -155,6 +156,12 @@ export function applyStreamedResolve(id, content) {
|
|
|
155
156
|
const tpl = document.createElement('template');
|
|
156
157
|
tpl.innerHTML = content;
|
|
157
158
|
const inserted = [...tpl.content.childNodes];
|
|
159
|
+
// A boundary whose content threw redirect() on the server streams a marker
|
|
160
|
+
// (#1686): follow it the way a first load follows its refresh.
|
|
161
|
+
for (const n of inserted) {
|
|
162
|
+
const to = n.nodeType === 1 ? /** @type {Element} */ (n).getAttribute('data-webjs-redirect') : null;
|
|
163
|
+
if (to) { navigate(to, { replace: true }); return; }
|
|
164
|
+
}
|
|
158
165
|
boundary.replaceWith(tpl.content);
|
|
159
166
|
// Upgrade any custom elements now that they are connected (belt-and-braces:
|
|
160
167
|
// a connected, defined element upgrades on insertion, but a fragment that was
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
import { scanSeeds } from '../action-seed-client.js';
|
|
10
|
+
import { syncRootAttributes } from './root-attrs.js';
|
|
10
11
|
import { collectBoundaries, planBoundarySwap } from './boundaries.js';
|
|
11
12
|
import { reportFallback } from './diagnostics.js';
|
|
12
13
|
import { blurOutgoingFocus, diffChildren, replaceBoundaryRange, resyncEnclosingHostSlots, swapMarkerRange } from './dom-differ.js';
|
|
@@ -113,6 +114,8 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
113
114
|
if (seedsScanned) return;
|
|
114
115
|
seedsScanned = true;
|
|
115
116
|
try { scanSeeds(doc, { frame: !!frameId }); } catch { /* seeding is best-effort */ }
|
|
117
|
+
// The root layout's <html> / <body> attributes follow the server (#1686).
|
|
118
|
+
if (!frameId) { try { syncRootAttributes(doc); } catch { /* best-effort */ } }
|
|
116
119
|
};
|
|
117
120
|
|
|
118
121
|
// Every host in this parsed doc is FRAMEWORK-SERIALIZED markup (an SSR
|
|
@@ -123,7 +126,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
123
126
|
// conditionally closed slot at snapshot time) where the structural
|
|
124
127
|
// slot-marker detector has nothing to see. The chooser consumes and removes
|
|
125
128
|
// the attribute on upgrade.
|
|
126
|
-
// (
|
|
129
|
+
// (A server-only host never upgrades, so its stamp is retained as
|
|
127
130
|
// an inert attribute; diffElementInPlace never copies it onto a live host,
|
|
128
131
|
// and the upgrade path consumes it, so it cannot mis-route anything.)
|
|
129
132
|
try {
|
package/src/routes.d.ts
CHANGED
|
@@ -73,16 +73,44 @@ export type Route = _HasRoutes extends true
|
|
|
73
73
|
? { [K in keyof WebjsRoutes]: RoutePattern<K & string> }[keyof WebjsRoutes]
|
|
74
74
|
: string;
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The params a route key's own text declares, read without a generated
|
|
78
|
+
* `RouteParamMap`: `[x]` is a `string`, `[...x]` a `string[]` (as in Next.js)
|
|
79
|
+
* and `[[...x]]` an optional `string[]`. Segments are read left to right from
|
|
80
|
+
* the first `[`, so every one is kept; the text up to the first `]` of
|
|
81
|
+
* `[[...x]]` is `[...x`, which is how the optional form is told apart.
|
|
82
|
+
*/
|
|
83
|
+
type InferredParams<K extends string> =
|
|
84
|
+
K extends `${string}[${infer Seg}]${infer Rest}`
|
|
85
|
+
? Seg extends `[...${infer N}`
|
|
86
|
+
? { [P in N]?: string[] } & InferredParams<Rest extends `]${infer After}` ? After : Rest>
|
|
87
|
+
: Seg extends `...${infer N}`
|
|
88
|
+
? { [P in N]: string[] } & InferredParams<Rest>
|
|
89
|
+
: { [P in Seg]: string } & InferredParams<Rest>
|
|
90
|
+
: {};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The params for a route key with no generated entry: what its text declares,
|
|
94
|
+
* or, for a static route and for the wide `string` key, the runtime default
|
|
95
|
+
* `Record<string, string>`.
|
|
96
|
+
*/
|
|
97
|
+
type FallbackParams<R extends string> = string extends R
|
|
98
|
+
? Record<string, string>
|
|
99
|
+
: keyof InferredParams<R> extends never
|
|
100
|
+
? Record<string, string>
|
|
101
|
+
: InferredParams<R>;
|
|
102
|
+
|
|
76
103
|
/**
|
|
77
104
|
* The params object for a route key. A generated dynamic route resolves to
|
|
78
105
|
* its `RouteParamMap` entry (`{ slug: string }`, `{ rest: string[] }`,
|
|
79
|
-
* `{ slug?: string[] }`)
|
|
80
|
-
*
|
|
81
|
-
*
|
|
106
|
+
* `{ slug?: string[] }`). Without one (an app that never ran `webjs types`),
|
|
107
|
+
* a dynamic route key still yields the same shape, read from the key itself,
|
|
108
|
+
* and a static route resolves to `Record<string, string>`, the runtime default
|
|
109
|
+
* the SSR pipeline produces.
|
|
82
110
|
*/
|
|
83
111
|
export type RouteParams<R extends string> = R extends keyof RouteParamMap
|
|
84
112
|
? RouteParamMap[R]
|
|
85
|
-
:
|
|
113
|
+
: FallbackParams<R>;
|
|
86
114
|
|
|
87
115
|
/**
|
|
88
116
|
* A value that is BOTH synchronously usable as `T` and `await`-able to `T`
|
|
@@ -97,11 +125,9 @@ export type Awaitable<T> = T & PromiseLike<T>;
|
|
|
97
125
|
* packages/server/src/ssr.js builds: `{ params, searchParams, url,
|
|
98
126
|
* actionData }`.
|
|
99
127
|
*
|
|
100
|
-
* `searchParams` is
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* last-wins `string`, never an array). The wider type keeps a future
|
|
104
|
-
* multi-value reader source-compatible.
|
|
128
|
+
* `searchParams` is `Record<string, string | string[]>`, as in Next.js: a key
|
|
129
|
+
* that repeats in the query (`?tag=a&tag=b`) is an array of every value, in
|
|
130
|
+
* order, and any other key is its string.
|
|
105
131
|
*
|
|
106
132
|
* `params` / `searchParams` are `Awaitable<T>` (#848): synchronously readable
|
|
107
133
|
* (`params.id`) AND `await`-able (`const { id } = await params`) for Next
|
|
@@ -109,13 +135,42 @@ export type Awaitable<T> = T & PromiseLike<T>;
|
|
|
109
135
|
* `then` (see packages/server/src/thenable-params.js), so a spread / JSON /
|
|
110
136
|
* `Object.keys` sees only the data keys.
|
|
111
137
|
*/
|
|
112
|
-
|
|
138
|
+
/**
|
|
139
|
+
* What a form action hands the page, by default: a failure result re-rendered
|
|
140
|
+
* (`{ success: false, error, fieldErrors }` is the usual shape) with the
|
|
141
|
+
* submitted text fields on `values`, or a success result carried across the
|
|
142
|
+
* redirect (`{ success: true, data }`). The common keys are typed; anything
|
|
143
|
+
* else the action returned reads as `any`, so `actionData?.data.manageKey`
|
|
144
|
+
* compiles without a cast, the way an untyped `useActionState` state does in
|
|
145
|
+
* Next. Name the exact shape with `PageProps`'s second type parameter,
|
|
146
|
+
* `PageProps<'/route', Awaited<ReturnType<typeof myAction>>>`, for full typing.
|
|
147
|
+
*/
|
|
148
|
+
export interface FormActionData {
|
|
149
|
+
success?: boolean;
|
|
150
|
+
error?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Per-field messages. Typed loosely, as `data` is: the page cannot see the
|
|
153
|
+
* action's return type, and actions return a `Record<string, string>` as
|
|
154
|
+
* often as a validator's `Record<string, string[]>`, so either assigns
|
|
155
|
+
* without a cast.
|
|
156
|
+
*/
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
+
fieldErrors?: Record<string, any>;
|
|
159
|
+
values?: Record<string, string>;
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
161
|
+
data?: any;
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
[key: string]: any;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface PageProps<R extends string = string, A = FormActionData> {
|
|
113
167
|
/**
|
|
114
|
-
* Path params
|
|
115
|
-
*
|
|
116
|
-
* `Record<string, string>`.
|
|
168
|
+
* Path params, narrowed to the route's shape (`{ slug: string }`,
|
|
169
|
+
* `{ rest: string[] }`) from the generated map or, without one, from the
|
|
170
|
+
* route key itself; a static route is `Record<string, string>`.
|
|
171
|
+
* Sync-readable and `await`-able (#848).
|
|
117
172
|
*/
|
|
118
|
-
params: Awaitable<R
|
|
173
|
+
params: Awaitable<RouteParams<R>>;
|
|
119
174
|
/** Query string, as an object. Repeated keys are last-wins at runtime. Sync-readable and `await`-able (#848). */
|
|
120
175
|
searchParams: Awaitable<Record<string, string | string[]>>;
|
|
121
176
|
/** The full request URL string. */
|
|
@@ -125,9 +180,11 @@ export interface PageProps<R extends string = string> {
|
|
|
125
180
|
* (#244). `undefined` on a normal GET render. Read `actionData.fieldErrors`
|
|
126
181
|
* / `actionData.values` to surface validation errors and repopulate inputs.
|
|
127
182
|
* On a failure `values` carries every submitted text field, with the action's
|
|
128
|
-
* own `values` on top (#1581).
|
|
183
|
+
* own `values` on top (#1581). Typed {@link FormActionData} by default; pass
|
|
184
|
+
* the action's result type as the second parameter,
|
|
185
|
+
* `PageProps<'/route', MyResult>`, to type it exactly.
|
|
129
186
|
*/
|
|
130
|
-
actionData?:
|
|
187
|
+
actionData?: A;
|
|
131
188
|
}
|
|
132
189
|
|
|
133
190
|
/**
|
|
@@ -135,7 +192,7 @@ export interface PageProps<R extends string = string> {
|
|
|
135
192
|
* the rendered `children`. Mirrors the `{ children, params, searchParams, url
|
|
136
193
|
* }` object packages/server/src/ssr.js passes a layout.
|
|
137
194
|
*/
|
|
138
|
-
export interface LayoutProps<R extends string = string> extends PageProps<R> {
|
|
195
|
+
export interface LayoutProps<R extends string = string, A = FormActionData> extends PageProps<R, A> {
|
|
139
196
|
/** The nested page / inner-layout content this layout must embed. */
|
|
140
197
|
children: TemplateResult;
|
|
141
198
|
}
|
|
@@ -149,5 +206,5 @@ export interface LayoutProps<R extends string = string> extends PageProps<R> {
|
|
|
149
206
|
*/
|
|
150
207
|
export interface RouteHandlerContext<R extends string = string> {
|
|
151
208
|
/** Sync-readable and `await`-able (#848), same as `PageProps['params']`. */
|
|
152
|
-
params: Awaitable<R
|
|
209
|
+
params: Awaitable<RouteParams<R>>;
|
|
153
210
|
}
|
package/src/serialize.d.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Error / typed arrays / Blob / File / FormData / Symbols / cycles).
|
|
4
4
|
*/
|
|
5
5
|
export function stringify(value: unknown): Promise<string>;
|
|
6
|
+
/** `stringify` done synchronously, for a value holding no File or Blob (it throws on one). */
|
|
7
|
+
export function stringifySync(value: unknown): string;
|
|
6
8
|
export function parse(text: string): unknown;
|
|
7
9
|
export function serialize(value: unknown): Promise<unknown>;
|
|
8
10
|
export function deserialize(value: unknown): unknown;
|
package/src/serialize.js
CHANGED
|
@@ -96,6 +96,23 @@ export async function stringify(value) {
|
|
|
96
96
|
return JSON.stringify(await serialize(value));
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* `stringify` for a value holding no Blob, File or FormData file, done
|
|
101
|
+
* synchronously. A bound form action's arguments need it: `.bind()` returns at
|
|
102
|
+
* once and the browser renderer reads the identity synchronously. Throws on a
|
|
103
|
+
* file, whose bytes can only be read asynchronously.
|
|
104
|
+
*
|
|
105
|
+
* @param {unknown} value
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function stringifySync(value) {
|
|
109
|
+
const ctx = newEncodeCtx();
|
|
110
|
+
if (walkRefs(value, ctx).length) {
|
|
111
|
+
throw new TypeError('[webjs] a File or Blob cannot be serialized synchronously (it cannot be a bound argument of a form action).');
|
|
112
|
+
}
|
|
113
|
+
return JSON.stringify(encodeOne(value, ctx));
|
|
114
|
+
}
|
|
115
|
+
|
|
99
116
|
/**
|
|
100
117
|
* Inverse of `stringify`. Synchronous: no async types need awaiting
|
|
101
118
|
* during decode (binary is already inlined as base64).
|
|
@@ -153,10 +170,24 @@ function newEncodeCtx() {
|
|
|
153
170
|
|
|
154
171
|
/**
|
|
155
172
|
* Pass 1: walk the value, count references, pre-read Blob/File bytes.
|
|
156
|
-
* Async because `Blob.arrayBuffer()` is async.
|
|
157
|
-
*
|
|
173
|
+
* Async because `Blob.arrayBuffer()` is async. The walk itself is the
|
|
174
|
+
* synchronous `walkRefs`; only the byte reads it collects are awaited.
|
|
158
175
|
*/
|
|
159
176
|
async function countRefs(value, ctx) {
|
|
177
|
+
for (const blob of walkRefs(value, ctx)) {
|
|
178
|
+
ctx.blobBytes.set(blob, new Uint8Array(await blob.arrayBuffer()));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Count references and assign ids to shared objects, returning the Blob/File
|
|
184
|
+
* values found (whose bytes the async path then reads). Iterative (an explicit
|
|
185
|
+
* stack) to avoid recursion limits on deeply nested values.
|
|
186
|
+
* @returns {Blob[]}
|
|
187
|
+
*/
|
|
188
|
+
function walkRefs(value, ctx) {
|
|
189
|
+
/** @type {Blob[]} */
|
|
190
|
+
const blobs = [];
|
|
160
191
|
const stack = [value];
|
|
161
192
|
while (stack.length) {
|
|
162
193
|
const v = stack.pop();
|
|
@@ -165,8 +196,7 @@ async function countRefs(value, ctx) {
|
|
|
165
196
|
ctx.refCount.set(v, cur + 1);
|
|
166
197
|
if (cur > 0) continue; // already traversed children
|
|
167
198
|
if (typeof Blob !== 'undefined' && v instanceof Blob) {
|
|
168
|
-
|
|
169
|
-
ctx.blobBytes.set(v, buf);
|
|
199
|
+
blobs.push(v);
|
|
170
200
|
continue;
|
|
171
201
|
}
|
|
172
202
|
if (typeof FormData !== 'undefined' && v instanceof FormData) {
|
|
@@ -195,6 +225,7 @@ async function countRefs(value, ctx) {
|
|
|
195
225
|
for (const [obj, count] of ctx.refCount) {
|
|
196
226
|
if (count > 1) ctx.idOf.set(obj, ctx.nextId++);
|
|
197
227
|
}
|
|
228
|
+
return blobs;
|
|
198
229
|
}
|
|
199
230
|
|
|
200
231
|
function encodeOne(v, ctx) {
|
package/src/signal.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export function computed<T>(fn: () => T, options?: Record<string, unknown>): Sig
|
|
|
15
15
|
export function isSignal(v: unknown): v is SignalState | SignalComputed;
|
|
16
16
|
export function effect(fn: () => void): () => void;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Lit's `SignalWatcher` mixin, an identity here: a `WebComponent` already
|
|
20
|
+
* re-renders when a signal it read changes.
|
|
21
|
+
*/
|
|
22
|
+
export function SignalWatcher<T extends abstract new (...args: any[]) => any>(Base: T): T;
|
|
23
|
+
export { watch } from './directives.js';
|
|
24
|
+
|
|
18
25
|
export const Signal: {
|
|
19
26
|
State: new <T>(initial: T, options?: Record<string, unknown>) => SignalState<T>;
|
|
20
27
|
Computed: new <T>(fn: () => T, options?: Record<string, unknown>) => SignalComputed<T>;
|
package/src/signal.js
CHANGED
|
@@ -426,7 +426,7 @@ class SignalComputed {
|
|
|
426
426
|
* Watcher
|
|
427
427
|
* ============================================================ */
|
|
428
428
|
|
|
429
|
-
class
|
|
429
|
+
class WatcherImpl {
|
|
430
430
|
/** @param {() => void} notifyCb */
|
|
431
431
|
constructor(notifyCb) {
|
|
432
432
|
this.__notify = notifyCb;
|
|
@@ -553,7 +553,7 @@ function untrack(fn) {
|
|
|
553
553
|
try { return fn(); } finally { activeConsumer = prev; }
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
-
/** @param {SignalComputed |
|
|
556
|
+
/** @param {SignalComputed | WatcherImpl} sink */
|
|
557
557
|
function introspectSources(sink) {
|
|
558
558
|
return sink.producers.slice();
|
|
559
559
|
}
|
|
@@ -568,7 +568,7 @@ function hasSinks(producer) {
|
|
|
568
568
|
return !!producer.liveConsumers && producer.liveConsumers.size > 0;
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
-
/** @param {SignalComputed |
|
|
571
|
+
/** @param {SignalComputed | WatcherImpl} sink */
|
|
572
572
|
function hasSources(sink) {
|
|
573
573
|
return sink.producers.length > 0;
|
|
574
574
|
}
|
|
@@ -618,7 +618,7 @@ export const Signal = {
|
|
|
618
618
|
State: SignalState,
|
|
619
619
|
Computed: SignalComputed,
|
|
620
620
|
subtle: {
|
|
621
|
-
Watcher:
|
|
621
|
+
Watcher: WatcherImpl,
|
|
622
622
|
untrack,
|
|
623
623
|
introspectSources,
|
|
624
624
|
introspectSinks,
|
|
@@ -662,7 +662,7 @@ export function isSignal(v) {
|
|
|
662
662
|
*
|
|
663
663
|
* Effects are useful for browser-only side effects such as DOM
|
|
664
664
|
* mutations, timers, or fetch calls. Avoid in render paths; the
|
|
665
|
-
* component's built-in
|
|
665
|
+
* component's built-in signal watching already covers re-render.
|
|
666
666
|
*
|
|
667
667
|
* The spec forbids reads/writes inside a Watcher notify, so effects
|
|
668
668
|
* defer the re-run to a microtask.
|
|
@@ -674,7 +674,7 @@ export function effect(fn) {
|
|
|
674
674
|
let scheduled = false;
|
|
675
675
|
let disposed = false;
|
|
676
676
|
const computed_ = new SignalComputed(fn);
|
|
677
|
-
const w = new
|
|
677
|
+
const w = new WatcherImpl(() => {
|
|
678
678
|
if (scheduled || disposed) return;
|
|
679
679
|
scheduled = true;
|
|
680
680
|
queueMicrotask(() => {
|
|
@@ -692,3 +692,23 @@ export function effect(fn) {
|
|
|
692
692
|
w.dispose();
|
|
693
693
|
};
|
|
694
694
|
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Lit's `SignalWatcher` mixin (`@lit-labs/signals`), here an IDENTITY: a
|
|
698
|
+
* `WebComponent` already re-renders when a signal it read changes, so
|
|
699
|
+
* `class X extends SignalWatcher(WebComponent)` is the same class as
|
|
700
|
+
* `class X extends WebComponent`. Kept so code written from Lit's signals
|
|
701
|
+
* package works unchanged.
|
|
702
|
+
*
|
|
703
|
+
* @template {abstract new (...args: any[]) => any} T
|
|
704
|
+
* @param {T} Base
|
|
705
|
+
* @returns {T}
|
|
706
|
+
*/
|
|
707
|
+
export function SignalWatcher(Base) {
|
|
708
|
+
return Base;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// `watch(signal)` from `@lit-labs/signals` is the fine-grained binding
|
|
712
|
+
// directive WebJs already ships, re-exported so `@webjsdev/core/signals`
|
|
713
|
+
// carries the whole Lit signals surface.
|
|
714
|
+
export { watch } from './directives.js';
|
package/src/slot/polyfills.js
CHANGED
|
@@ -186,11 +186,11 @@ export function manualSlotFor(state, node) {
|
|
|
186
186
|
/**
|
|
187
187
|
* Walk up from a light slot to its owning host: the nearest SLOT_STATE
|
|
188
188
|
* ancestor that actually OWNS the slot. A slot separated from that ancestor
|
|
189
|
-
* by another custom element (a forwarded slot inside a foreign /
|
|
189
|
+
* by another custom element (a forwarded slot inside a foreign / server-only
|
|
190
190
|
* component) is nobody's here: attributing it to the outer host would
|
|
191
191
|
* redirect the outer host's own same-named slot, so `assign()` on such a
|
|
192
|
-
* slot is inert instead (the same carve-out as every other
|
|
193
|
-
*
|
|
192
|
+
* slot is inert instead (the same carve-out as every other write to a
|
|
193
|
+
* server-only component).
|
|
194
194
|
*/
|
|
195
195
|
function hostOfSlot(slot) {
|
|
196
196
|
for (let p = slot.parentElement; p; p = p.parentElement) {
|
package/src/slot.js
CHANGED
|
@@ -59,14 +59,10 @@
|
|
|
59
59
|
* it); read assigned content from `slotchange` or after a microtask.
|
|
60
60
|
*
|
|
61
61
|
* Live writes need the component's JS on the page. Interception + sensors
|
|
62
|
-
* install in connectedCallback, so a component
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* references its tag); for an imperative consumer reaching it through a
|
|
67
|
-
* string selector the analyser cannot see, force the ship with
|
|
68
|
-
* `static interactive = true`. Shadow components always ship (the DSD
|
|
69
|
-
* carve-out), so this is the one place elision, not slots, sets the boundary.
|
|
62
|
+
* install in connectedCallback, so a server-only component (one in a
|
|
63
|
+
* `*.server.ts` file) ships no JS and its post-mount native writes are inert,
|
|
64
|
+
* like anything else on a server-only component. Every component in an
|
|
65
|
+
* ordinary file ships (#1686), so it always has them.
|
|
70
66
|
*
|
|
71
67
|
* Polyfill safety. Every prototype patch checks for the `data-webjs-light`
|
|
72
68
|
* attribute and falls through to native otherwise, so real shadow-DOM slots
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TemplateResult } from './html.js';
|
|
2
|
+
|
|
3
|
+
/** A value spliced into a template's strings (lit's `StaticValue`). */
|
|
4
|
+
export interface StaticValue {
|
|
5
|
+
readonly _$webjsStatic: string;
|
|
6
|
+
readonly r: symbol;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** lit's `unsafeStatic`: trusted text inserted as static markup. NEVER user input. */
|
|
10
|
+
export function unsafeStatic(value: string): StaticValue;
|
|
11
|
+
/** lit's `literal`: a static value whose interpolations must themselves be static. */
|
|
12
|
+
export function literal(strings: TemplateStringsArray, ...values: unknown[]): StaticValue;
|
|
13
|
+
/** lit's `withStatic`: wrap a template tag so it accepts static values. */
|
|
14
|
+
export function withStatic<R>(
|
|
15
|
+
coreTag: (strings: TemplateStringsArray | string[], ...values: unknown[]) => R,
|
|
16
|
+
): (strings: TemplateStringsArray | string[], ...values: unknown[]) => R;
|
|
17
|
+
/** `html` that accepts `literal` / `unsafeStatic` values in any position. */
|
|
18
|
+
export function html(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult;
|
|
19
|
+
/** `svg` that accepts static values. */
|
|
20
|
+
export function svg(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult;
|
|
21
|
+
/** `mathml` that accepts static values. */
|
|
22
|
+
export function mathml(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult;
|