@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.
Files changed (85) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +20 -19
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +14 -9
  9. package/index.d.ts +53 -10
  10. package/index.js +17 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.d.ts +18 -0
  16. package/src/asset-url.js +12 -13
  17. package/src/async-directive.d.ts +18 -0
  18. package/src/async-directive.js +88 -0
  19. package/src/attribute-reader.js +197 -23
  20. package/src/binding-prefixes.js +0 -12
  21. package/src/component/lifecycle.js +760 -209
  22. package/src/component/reactive.js +2 -10
  23. package/src/component/server-element.js +23 -1
  24. package/src/component.d.ts +151 -41
  25. package/src/context.d.ts +94 -18
  26. package/src/context.js +341 -217
  27. package/src/converters.d.ts +7 -0
  28. package/src/converters.js +38 -0
  29. package/src/csp-nonce.d.ts +18 -0
  30. package/src/css.d.ts +32 -6
  31. package/src/css.js +178 -14
  32. package/src/directive.d.ts +58 -0
  33. package/src/directive.js +119 -0
  34. package/src/directives.d.ts +38 -1
  35. package/src/directives.js +158 -1
  36. package/src/form-action.d.ts +13 -0
  37. package/src/form-action.js +139 -4
  38. package/src/hot.js +23 -0
  39. package/src/html.d.ts +4 -0
  40. package/src/html.js +35 -3
  41. package/src/is-server.d.ts +2 -0
  42. package/src/is-server.js +18 -0
  43. package/src/jsx-guard.js +130 -0
  44. package/src/metadata.d.ts +22 -3
  45. package/src/nav.d.ts +4 -1
  46. package/src/nav.js +44 -11
  47. package/src/optimistic.js +2 -2
  48. package/src/refresh-header.js +52 -0
  49. package/src/registry.js +10 -1
  50. package/src/render-client/adopt-slots.js +8 -3
  51. package/src/render-client/parts.js +745 -31
  52. package/src/render-client/reconciler.js +56 -11
  53. package/src/render-client/template-compiler.js +139 -44
  54. package/src/render-client.d.ts +5 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +34 -0
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +61 -8
  59. package/src/render-server/served-attrs.js +1 -2
  60. package/src/render-server/walk.js +135 -3
  61. package/src/render-server.d.ts +3 -0
  62. package/src/render-server.js +2 -0
  63. package/src/repeat.d.ts +7 -2
  64. package/src/repeat.js +20 -6
  65. package/src/router-client/dom-differ.js +17 -0
  66. package/src/router-client/fetch-apply.js +86 -5
  67. package/src/router-client/navigator.js +45 -9
  68. package/src/router-client/prefetch.js +3 -4
  69. package/src/router-client/root-attrs.js +43 -0
  70. package/src/router-client/stream.js +7 -0
  71. package/src/router-client/swap.js +4 -1
  72. package/src/routes.d.ts +75 -18
  73. package/src/serialize.d.ts +2 -0
  74. package/src/serialize.js +35 -4
  75. package/src/signal.d.ts +7 -0
  76. package/src/signal.js +26 -6
  77. package/src/slot/polyfills.js +3 -3
  78. package/src/slot.js +4 -8
  79. package/src/static-html.d.ts +22 -0
  80. package/src/static-html.js +144 -0
  81. package/src/task.d.ts +63 -20
  82. package/src/task.js +326 -293
  83. package/src/testing.d.ts +42 -0
  84. package/src/webjs-config.d.ts +1 -8
  85. 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
- /** @type any */ (globalThis).__webjsRefreshPage = refreshPage;
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
- if (!enabled || typeof location === 'undefined') return false;
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, which covers a
460
- // `loading.{js,ts}` either direction (it can never be elided, so it is always
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
- if (!res || res.aborted) return true;
486
- return res.applied;
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 false;
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
- const optimisticState = applyOptimisticLoading();
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}` reloads either way, though for two different
96
- * reasons: editing or deleting one reloads because it can never be elided and
97
- * so always ships, while ADDING one reloads because a brand-new file is in no
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
- // (An ELIDED display-only host never upgrades, so its stamp is retained as
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[] }`); anything else (a static route, or any route in an
80
- * un-generated app) resolves to `Record<string, string>`, the runtime
81
- * default the SSR pipeline produces.
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
- : Record<string, string>;
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 typed `Record<string, string | string[]>` to match the
101
- * established page-context convention, even though the runtime builds it with
102
- * `Object.fromEntries(url.searchParams.entries())` (so a repeated key is
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
- export interface PageProps<R extends string = string> {
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. A generated dynamic route narrows this to its exact shape
115
- * (`{ slug: string }`); a static or un-generated route is
116
- * `Record<string, string>`. Sync-readable and `await`-able (#848).
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 extends keyof RouteParamMap ? RouteParamMap[R] : Record<string, string>>;
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?: unknown;
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 extends keyof RouteParamMap ? RouteParamMap[R] : Record<string, string>>;
209
+ params: Awaitable<RouteParams<R>>;
153
210
  }
@@ -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. Iterative (uses an
157
- * explicit stack) to avoid recursion limits on deeply nested values.
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
- const buf = new Uint8Array(await v.arrayBuffer());
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 SignalWatcher {
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 | SignalWatcher} sink */
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 | SignalWatcher} sink */
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: SignalWatcher,
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 SignalWatcher already covers re-render.
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 SignalWatcher(() => {
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';
@@ -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 / elided
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 elided-component
193
- * write).
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 the framework ELIDES (a
63
- * display-only slotted wrapper with no client signal) ships no JS and its
64
- * post-mount native writes are inert, like anything on an elided component.
65
- * A component that is actually interacted with ships (a client module
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;