@webjsdev/core 0.7.65 → 0.9.0

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 (92) 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 +22 -21
  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 +18 -9
  9. package/index.d.ts +26 -10
  10. package/index.js +21 -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.js +12 -13
  16. package/src/async-directive.d.ts +18 -0
  17. package/src/async-directive.js +88 -0
  18. package/src/attribute-directives.d.ts +6 -0
  19. package/src/attribute-directives.js +49 -0
  20. package/src/attribute-reader.js +213 -25
  21. package/src/binding-prefixes.js +10 -53
  22. package/src/component/lifecycle.js +921 -252
  23. package/src/component/reactive.js +7 -14
  24. package/src/component/server-element.js +23 -1
  25. package/src/component.d.ts +160 -44
  26. package/src/context.d.ts +94 -18
  27. package/src/context.js +341 -217
  28. package/src/converters.d.ts +7 -0
  29. package/src/converters.js +38 -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 +40 -1
  35. package/src/directives.js +160 -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 +8 -0
  40. package/src/html.js +55 -2
  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 +250 -0
  51. package/src/render-client/parts.js +790 -42
  52. package/src/render-client/reconciler.js +84 -23
  53. package/src/render-client/template-compiler.js +152 -43
  54. package/src/render-client.d.ts +6 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +50 -2
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +100 -20
  59. package/src/render-server/served-attrs.js +78 -0
  60. package/src/render-server/walk.js +177 -5
  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/anchors.js +3 -1
  66. package/src/router-client/boundaries.js +15 -1
  67. package/src/router-client/constants.js +30 -0
  68. package/src/router-client/dom-differ.js +17 -0
  69. package/src/router-client/fetch-apply.js +107 -5
  70. package/src/router-client/frames.js +6 -3
  71. package/src/router-client/head-merge.js +58 -10
  72. package/src/router-client/navigator.js +49 -9
  73. package/src/router-client/owned-styles.js +372 -0
  74. package/src/router-client/prefetch.js +6 -4
  75. package/src/router-client/root-attrs.js +43 -0
  76. package/src/router-client/stream.js +7 -0
  77. package/src/router-client/stylesheets.js +191 -0
  78. package/src/router-client/swap.js +53 -10
  79. package/src/routes.d.ts +75 -18
  80. package/src/sentinels.d.ts +2 -0
  81. package/src/sentinels.js +4 -0
  82. package/src/serialize.d.ts +2 -0
  83. package/src/serialize.js +35 -4
  84. package/src/signal.d.ts +7 -0
  85. package/src/signal.js +26 -6
  86. package/src/slot/polyfills.js +3 -3
  87. package/src/slot.js +4 -8
  88. package/src/static-html.d.ts +22 -0
  89. package/src/static-html.js +144 -0
  90. package/src/task.d.ts +63 -20
  91. package/src/task.js +326 -293
  92. package/src/webjs-config.d.ts +2 -9
@@ -7,12 +7,14 @@
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';
13
14
  import { trackedReloadSignature } from './frames.js';
14
- import { addNewHeadElements, mergeHead } from './head-merge.js';
15
+ import { addNewHeadElements, mergeHead, mergeHeadStylesheets } from './head-merge.js';
15
16
  import { morphLayoutChrome, planLayoutChrome } from './layout-chrome.js';
17
+ import { primeLiveStylesheetKeys, releaseIncomingSheets, switchOffDepartedStylesheets } from './owned-styles.js';
16
18
  import { clearPrefetchRefused, prefetchClearAll } from './prefetch.js';
17
19
  import { snapshotCache } from './snapshot-cache.js';
18
20
  import { hardNavigate } from './state.js';
@@ -84,6 +86,13 @@ export let _swapCommit = Promise.resolve();
84
86
  * history (a revalidation, a refresh, the popstate restore).
85
87
  */
86
88
  export function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc, refresh, recordHistoryNow) {
89
+ // The URL the incoming page came from, which its relative stylesheet hrefs
90
+ // resolve against, and the live sheets' keys taken NOW, while the document
91
+ // still has the outgoing page's URL: every commit point below records the
92
+ // new history entry before it merges the head (#1669).
93
+ let incomingBase = typeof location !== 'undefined' ? location.href : '';
94
+ if (href) { try { incomingBase = new URL(href, incomingBase).href; } catch { /* keep the live url */ } }
95
+ primeLiveStylesheetKeys();
87
96
  // SSR action seeding (#472): ingest the incoming page's seed payload BEFORE
88
97
  // its components are grafted into the live DOM and upgrade, so a
89
98
  // soft-navigated async component resolves from the seed instead of
@@ -105,6 +114,8 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
105
114
  if (seedsScanned) return;
106
115
  seedsScanned = true;
107
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 */ } }
108
119
  };
109
120
 
110
121
  // Every host in this parsed doc is FRAMEWORK-SERIALIZED markup (an SSR
@@ -115,7 +126,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
115
126
  // conditionally closed slot at snapshot time) where the structural
116
127
  // slot-marker detector has nothing to see. The chooser consumes and removes
117
128
  // the attribute on upgrade.
118
- // (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
119
130
  // an inert attribute; diffElementInPlace never copies it onto a live host,
120
131
  // and the upgrade path consumes it, so it cannot mis-route anything.)
121
132
  try {
@@ -279,7 +290,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
279
290
  // ADD-ONLY head merge: preserve runtime-generated head content
280
291
  // (Tailwind CSS injection, etc.) that the outer layout's scripts
281
292
  // already produced.
282
- addNewHeadElements(doc.head);
293
+ addNewHeadElements(doc.head, { base: incomingBase, frame: true });
283
294
  // `diffChildren` -> `reconcileChildren` regrafts permanent elements
284
295
  // by node identity (it imports the incoming children first, then
285
296
  // swaps the live permanent node into the imported tree), so the live
@@ -288,6 +299,8 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
288
299
  // ALSO progressively streams a Suspense boundary gates its resolves on the
289
300
  // committed frame swap, not a stale prior _swapCommit (#1048).
290
301
  _swapCommit = runWithTransition(() => {
302
+ // The frame's incoming sheets loaded held; they apply with the swap.
303
+ releaseIncomingSheets();
291
304
  diffChildren(target, source);
292
305
  reactivateScripts(target);
293
306
  upgradeCustomElements(target);
@@ -334,7 +347,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
334
347
  // commit point without a reader having to prove reachability first, which
335
348
  // is what stops the next person adding a commit point from omitting it.
336
349
  if (recordHistoryNow) recordHistoryNow();
337
- swapFullBody(doc);
350
+ swapFullBody(doc, incomingBase, true);
338
351
  return;
339
352
  }
340
353
 
@@ -361,7 +374,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
361
374
  if (plan && refresh === 'layouts' && !chrome) {
362
375
  ingestSeeds();
363
376
  if (recordHistoryNow) recordHistoryNow();
364
- swapFullBody(doc);
377
+ swapFullBody(doc, incomingBase, true);
365
378
  return;
366
379
  }
367
380
 
@@ -375,11 +388,32 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
375
388
  if (recordHistoryNow) recordHistoryNow();
376
389
  const { mode, live, incoming } = plan;
377
390
  // ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
378
- // runtime state (Tailwind injection, etc.) must not be invalidated.
379
- addNewHeadElements(doc.head);
391
+ // runtime state (Tailwind injection, etc.) must not be invalidated. It runs
392
+ // ahead of the commit, as the incoming sheets' load-before-swap copies
393
+ // already do (#1666, Turbo's order), so the new page's sheets apply from
394
+ // the swap's first frame.
395
+ // Its stylesheets wait for the commit (#1669, below).
396
+ addNewHeadElements(doc.head, { base: incomingBase, styles: false });
397
+ // A live element inside the range this swap replaces is on its way out,
398
+ // so a stylesheet link there does not count as one the page keeps.
399
+ /** @param {Element} el */
400
+ const leaving = (el) => !!(live.start.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
401
+ && !!(live.end.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING);
380
402
  _swapCommit = runWithTransition(() => {
403
+ // Everything stylesheet-related happens inside the commit, so the
404
+ // outgoing page, and a view transition's capture of it, keep exactly the
405
+ // sheets that page used, and the crossing restyles once (#1669): the
406
+ // incoming sheets (loaded held, stylesheets.js) are adopted, switched
407
+ // back on or inserted, then released, before the new markup lands.
408
+ mergeHeadStylesheets(doc.head, { base: incomingBase, segment: plan.segment, leaving });
409
+ releaseIncomingSheets();
381
410
  if (mode === 'replace') replaceBoundaryRange(live, incoming);
382
411
  else swapMarkerRange(live, incoming, doc);
412
+ // The swapped range's own stylesheets go with it unless the incoming
413
+ // page links them, in its head or its body, on a replace and on a morph
414
+ // alike: the server re-rendered the whole range either way, so a sheet a
415
+ // searchParams change dropped is retired too.
416
+ switchOffDepartedStylesheets(plan.segment, doc, incomingBase);
383
417
  // No key sync is needed on the anchor's own comments: the plan's anchor
384
418
  // carries EQUAL live/incoming route-keys in every tier (a changed-key
385
419
  // REPLACE anchors at a parent already compared equal; the other tiers
@@ -438,7 +472,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
438
472
  // because the user is already viewing the page).
439
473
  ingestSeeds(); // committed: past both discard branches above
440
474
  if (recordHistoryNow) recordHistoryNow();
441
- swapFullBody(doc);
475
+ swapFullBody(doc, incomingBase, false);
442
476
  }
443
477
 
444
478
  /**
@@ -460,9 +494,12 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
460
494
  * it only past their own discard branches.
461
495
  *
462
496
  * @param {Document} doc
497
+ * @param {string} incomingBase the URL the incoming page came from
498
+ * @param {boolean} complete the response rendered every layout (no X-Webjs-Have), so it can retire a departed section's sheets
463
499
  */
464
- function swapFullBody(doc) {
465
- mergeHead(doc.head);
500
+ function swapFullBody(doc, incomingBase, complete) {
501
+ // Its stylesheets wait for the commit, as on a boundary swap (#1669).
502
+ mergeHead(doc.head, { base: incomingBase, styles: false });
466
503
  // Persist permanent elements by node identity across the full-body
467
504
  // swap: move each live [data-webjs-permanent][id] node into the matching
468
505
  // position in the incoming body BEFORE replaceChildren reads it, so the
@@ -470,7 +507,13 @@ function swapFullBody(doc) {
470
507
  regraftPermanentElements(document.body, doc.body);
471
508
  const newChildren = [...doc.body.childNodes];
472
509
  const doSwap = () => {
510
+ // The whole body is replaced, so every live body link is leaving.
511
+ mergeHeadStylesheets(doc.head, { base: incomingBase, segment: '/', leaving: () => true });
512
+ releaseIncomingSheets();
473
513
  document.body.replaceChildren(...newChildren);
514
+ // Only a response known to be complete can say a section's sheet is gone:
515
+ // the background restore path's head can be a reduced one (#936).
516
+ if (complete) switchOffDepartedStylesheets('/', doc, incomingBase);
474
517
  reactivateScripts(document.body);
475
518
  upgradeCustomElements(document.body);
476
519
  blurOutgoingFocus();
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
  }
@@ -0,0 +1,2 @@
1
+ export const nothing: unique symbol;
2
+ export const noChange: unique symbol;
@@ -0,0 +1,4 @@
1
+ /** Clear a rendered value using Lit's position-specific rules. */
2
+ export const nothing = Symbol.for('webjs.nothing');
3
+ /** Keep the previously committed value. */
4
+ export const noChange = Symbol.for('webjs.noChange');
@@ -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;