@weftui/core 0.27.1 → 0.29.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.
@@ -2,82 +2,219 @@
2
2
  title: "@weftui/dom"
3
3
  order: 2
4
4
  section: reference
5
- description: Full API surface for @weftui/dom the client renderer (mount, hydrate) and the server renderer (renderToString and streaming variants).
5
+ description: Full API surface for @weftui/dom, covering the WeftApp client runtime (make, mount, hydrate, errors, dispose), the server renderer (renderToString and streaming variants), and the Props.merge/Props.cx prop-bag composition utilities.
6
6
  ---
7
7
 
8
8
  # @weftui/dom API Reference
9
9
 
10
- The DOM renderer for Weft. It has two entry points `@weftui/dom/client` for the
11
- browser and `@weftui/dom/server` for Node plus a package root that re-exports the
12
- renderer error types. See the [Server-Side Rendering guide](https://weftui.dev/docs/how-to/render-on-the-server)
10
+ The DOM renderer for Weft. It has two entry points: `@weftui/dom/client` for the
11
+ browser and `@weftui/dom/server` for Node. A package root re-exports the renderer
12
+ error types. See the [Server-Side Rendering guide](https://weftui.dev/docs/how-to/render-on-the-server)
13
13
  for a narrative walkthrough.
14
14
 
15
15
  ## `@weftui/dom/client`
16
16
 
17
- ### `mount`
17
+ `WeftApp` is the client entry point's app namespace (`export * as WeftApp from
18
+ "./weft-app"`). One `WeftApp` value is one lazily-built `ManagedRuntime` (the app
19
+ layer) + one root `Scope` + one unhandled-error hub.
20
+
21
+ Each `WeftApp.mount` / `WeftApp.hydrate` call creates a child **root scope** under
22
+ the app scope. Layer-built services are shared **by reference** across every root
23
+ mounted from the same app (layer memoization). This is what makes cross-island
24
+ reactive state work (see [examples/shared-state-islands](https://github.com/stefvw93/weft/tree/main/examples/shared-state-islands)).
25
+
26
+ The barrel also re-exports `MountError`, `HydrateError`, `RootHandle`,
27
+ `UnhandledError`, and the `WeftApp` interface's type as `WeftAppType` (renamed on
28
+ export to avoid colliding with the `WeftApp` namespace import).
29
+
30
+ ### `WeftApp.make`
31
+
32
+ ```ts
33
+ const make: {
34
+ (): WeftApp<never, never>;
35
+ <R, E>(
36
+ layer: Layer.Layer<R, E, never>,
37
+ options?: { readonly memoMap?: Layer.MemoMap },
38
+ ): WeftApp<R, E>;
39
+ };
40
+ ```
41
+
42
+ Creates a `WeftApp` from an app layer. `make` is synchronous and side-effect-free
43
+ with respect to the layer: the layer builds **lazily** on the first `mount` /
44
+ `hydrate` (or the first direct `app.runtime` run), per `ManagedRuntime.make`
45
+ semantics.
46
+
47
+ A layer whose construction has an observable side effect shows that effect only
48
+ after the first mount, never at `make` time. `options.memoMap` shares layer
49
+ memoization across multiple `WeftApp` instances.
50
+
51
+ There is deliberately no `makeScoped`. To bind an app's lifetime to a scope,
52
+ compose it yourself:
53
+
54
+ ```ts
55
+ const acquireApp = Effect.acquireRelease(
56
+ Effect.sync(() => WeftApp.make(AppLive)),
57
+ (app) => WeftApp.dispose(app),
58
+ );
59
+ ```
60
+
61
+ ### `WeftApp.mount`
62
+
63
+ ```ts
64
+ const mount: <R, E>(
65
+ app: WeftApp<R, E>,
66
+ node: Renderable,
67
+ root: HTMLElement,
68
+ ) => Effect.Effect<RootHandle, E | MountError>;
69
+ ```
70
+
71
+ Mounts `node` into `root` as a new root of `app`. The returned effect is
72
+ self-contained: its requirement channel is `never`, so it runs with a bare
73
+ `Effect.runPromise`. Services come exclusively from the app layer; an
74
+ `Effect.provide` wrapped around this call does not reach components.
75
+
76
+ Clears `root`'s existing children, renders, appends the result. Completes after
77
+ initial render; streams keep running in the background, owned by the root's scope
78
+ (a child of the app scope).
79
+
80
+ The app layer builds lazily here on first mount; its error channel `E` surfaces at
81
+ that point. On render failure the root scope is closed before the error propagates;
82
+ the app runtime and other roots are untouched. Mounting on a disposed app fails
83
+ rather than hanging.
84
+
85
+ ### `WeftApp.hydrate`
86
+
87
+ ```ts
88
+ function hydrate<A extends Renderable, R = never, E = never>(
89
+ app: WeftApp<R, E>,
90
+ node: A,
91
+ root: HTMLElement,
92
+ ): [AssertNoServerOnly<CoreNode.Context<A>>] extends [CoreNode.Context<A>]
93
+ ? Effect.Effect<RootHandle, E | HydrateError>
94
+ : ServerOnlyLeak;
95
+ ```
96
+
97
+ Continues, on the client, the DOM produced on the server by
98
+ `renderToStringHydratable` / `renderToStreamHydratable`, as a new root of `app`.
99
+ Unlike `mount`, does **not** clear `root`: it walks the node tree in lockstep with
100
+ the existing server DOM, adopting nodes in place. Error channel is `E |
101
+ HydrateError` (adds `HydrationMismatchError` on top of everything `mount` can fail
102
+ with).
103
+
104
+ Preserves the compile-time `AssertNoServerOnly` → `ServerOnlyLeak` guard. A
105
+ server-only requirement left in `node`'s context degrades the return type to the
106
+ `ServerOnlyLeak` sentinel (compile error at the call site), not a runtime failure.
107
+ Hydration mechanics (the readiness barrier, stream-id seeding) are otherwise
108
+ unchanged from `mount`.
109
+
110
+ ### `WeftApp.errors`
111
+
112
+ ```ts
113
+ const errors: <R, E>(app: WeftApp<R, E>) => Stream.Stream<UnhandledError>;
114
+ ```
115
+
116
+ The app's unhandled-error stream. While at least one subscriber exists, the default
117
+ `Effect.logError` fallback is suppressed and every `UnhandledError` is delivered to
118
+ all subscribers. With zero subscribers, each unhandled error runs the default log
119
+ (annotated with `weft.region`) instead.
120
+
121
+ There is no replay: a subscriber sees only errors published after it subscribed.
122
+ Multiple concurrent subscribers each receive every subsequent error. When the last
123
+ subscriber unsubscribes, the default log resumes.
124
+
125
+ ### `WeftApp.dispose`
126
+
127
+ ```ts
128
+ const dispose: <R, E>(app: WeftApp<R, E>) => Effect.Effect<void>;
129
+ ```
130
+
131
+ Disposes the app, in order:
132
+
133
+ - closes every root scope (in mount order),
134
+ - releases the runtime's layers (`runtime.disposeEffect`),
135
+ - shuts the error hub down.
136
+
137
+ Idempotent: teardown effects run once. Subsequent `mount` / `hydrate` calls fail.
138
+
139
+ ### `WeftApp<R, E>` (`WeftAppType`)
18
140
 
19
141
  ```ts
20
- mount(node: Renderable, target: Element): Effect<MountHandle, RenderError, R>
142
+ interface WeftApp<in R = never, out E = never> {
143
+ readonly [TypeId]: typeof TypeId;
144
+ readonly runtime: ManagedRuntime.ManagedRuntime<R, E>;
145
+ }
21
146
  ```
22
147
 
23
- Renders a Weft `node` into `target` for a fresh (non-SSR) page, building real DOM
24
- and starting every reactive stream. Returns a `MountHandle` whose scope owns the
25
- mounted tree; closing it tears the tree down. Use `mount` for purely client-rendered
26
- apps; use `hydrate` when the markup already exists from SSR.
148
+ Re-exported from the barrel as `WeftAppType`. `runtime` is the app's
149
+ `ManagedRuntime`, for running app-level effects against the shared layer outside
150
+ any root. Examples: `app.runtime.runFork(trackPageviews)` (see
151
+ `website/src/entry-client.ts`) and `app.runtime.runPromise(Router.push("/about"))`.
27
152
 
28
- ### `hydrate`
153
+ ### `RootHandle`
29
154
 
30
155
  ```ts
31
- hydrate(node: Renderable, target: Element): Effect<MountHandle, HydrationMismatchError | RenderError, R>
156
+ interface RootHandle {
157
+ readonly element: HTMLElement;
158
+ unmount(): Effect.Effect<void>;
159
+ }
32
160
  ```
33
161
 
34
- Adopts server-rendered DOM **in place** inside `target` and resumes reactivity
35
- without re-creating elements. The `node` must produce a tree structurally identical
36
- to what the server rendered; a divergence fails with `HydrationMismatchError`. This
37
- is the flash-free path: no second render, the existing nodes simply become live.
162
+ Returned by `mount` / `hydrate`. `element` is the DOM element the root was mounted
163
+ into. `unmount()` closes **this root's scope only**: it interrupts its stream
164
+ subscriptions and any scoped work forked from its event handlers.
165
+
166
+ It does **not** dispose the app runtime, touch other roots, or remove the rendered
167
+ DOM nodes from `element`. Idempotent: teardown side effects fire once.
38
168
 
39
- ### `mountScoped`
169
+ ### `UnhandledError`
40
170
 
41
171
  ```ts
42
- mountScoped(app: Renderable, root: HTMLElement): Effect<MountHandle, UnsupportedNodeTypeError | StreamSubscriptionError | RenderError, Scope.Scope>
172
+ interface UnhandledError {
173
+ readonly cause: Cause.Cause<unknown>;
174
+ readonly region: string;
175
+ readonly root: RootHandle;
176
+ }
43
177
  ```
44
178
 
45
- Scope-aware `mount`: identical behavior, but requires an ambient `Scope.Scope` in
46
- `R` and registers `unmount` as a finalizer on it, so the mount lives until that
47
- scope closes rather than only until the mount effect resolves. Provide any scoped
48
- layer **outside** a long-lived scoped region so it outlives initial render — see
49
- [Provide Services](https://weftui.dev/docs/how-to/provide-services) for the composition and
50
- [Layer lifetime at the mount](https://weftui.dev/docs/explanation/services-and-context#layer-lifetime-at-the-mount)
51
- for why.
179
+ An error that escaped every user-level handler and reached the app's
180
+ unhandled-error hub, published on `WeftApp.errors(app)`. `region` identifies where
181
+ in the render tree the error escaped. Sources (one entry per failing occurrence):
182
+
183
+ - a rendered stream subscription failing or dying with **no enclosing `Boundary`**
184
+ (region e.g. `"attribute:class"`, `"child:stream-3"`),
185
+ - an error escaping the **outermost** `Boundary` recovery (region
186
+ `"boundary:outermost"`),
187
+ - an event-handler effect **failing or dying** (region `"event:onClick"`), reported
188
+ in development and production alike (there is no `NODE_ENV`-gated swallow).
189
+
190
+ Interrupt-only causes are never published. Errors handled by a nested `Boundary`
191
+ never reach the hub.
52
192
 
53
- ### `hydrateScoped`
193
+ ### `MountError`
54
194
 
55
195
  ```ts
56
- hydrateScoped(app: Renderable, root: HTMLElement): Effect<MountHandle, UnsupportedNodeTypeError | StreamSubscriptionError | RenderError | HydrationMismatchError, Scope.Scope>
196
+ type MountError = UnsupportedNodeTypeError | StreamSubscriptionError | RenderError;
57
197
  ```
58
198
 
59
- Scope-aware `hydrate` same relationship as `mountScoped` to `mount`, with
60
- `hydrate`'s error union (`HydrationMismatchError` added) and the same client-only
61
- compile-time guard: a server-only requirement left in `app`'s `R` degrades the
62
- return type to `ServerOnlyLeak` via `AssertNoServerOnly`.
199
+ Errors `mount` can fail with, beyond the app layer's own error channel `E`.
63
200
 
64
- ### `MountHandle`
201
+ ### `HydrateError`
65
202
 
66
- The handle returned by `mount`, `hydrate`, `mountScoped`, and `hydrateScoped`. Its
67
- `unmount()` interrupts every subscription and event handler and disposes the
68
- mount's `ManagedRuntime`; it does **not** remove the mounted DOM nodes from `root`.
69
- `unmount` is idempotent — safe to call more than once, including once
70
- automatically and once explicitly.
203
+ ```ts
204
+ type HydrateError = MountError | HydrationMismatchError;
205
+ ```
206
+
207
+ Everything `MountError` covers, plus `HydrationMismatchError` when the server DOM
208
+ and the node tree diverge.
209
+
210
+ ### `TypeId`
71
211
 
72
- The runtime backing the handle lives until `unmount` runs, not until the
73
- `mount`/`hydrate` effect resolves that effect completes right after initial
74
- render, while streams and handlers keep running in the background. If `mount` or
75
- `hydrate` runs inside a region that supplies an ambient `Scope.Scope` (e.g. under
76
- `Effect.scoped`), `unmount` is auto-registered on that scope as a finalizer, so the
77
- mount tears down when the scope closes; with no ambient scope, behavior is
78
- unchanged and `unmount` must be called explicitly. `mountScoped`/`hydrateScoped`
79
- register the same finalizer explicitly, so the typed variant does not silently
80
- depend on this auto-registration.
212
+ ```ts
213
+ const TypeId: unique symbol; // Symbol.for("@weftui/dom/WeftApp")
214
+ ```
215
+
216
+ The unique brand for `WeftApp` values. Internal identity marker; rarely referenced
217
+ directly.
81
218
 
82
219
  ## `@weftui/dom/server`
83
220
 
@@ -115,28 +252,228 @@ hydration markers. These back streaming SSR and suspense.
115
252
  renderToHydratableShell(node: Renderable): Effect<HydratableShell, Error, R>
116
253
  ```
117
254
 
118
- Produces a `HydratableShell` the document scaffold around the app for servers
255
+ Produces a `HydratableShell` (the document scaffold around the app) for servers
119
256
  that assemble the response shell separately from the streamed body.
120
257
 
121
258
  ### Suspense failure handling
122
259
 
123
- `SuspenseFailureHandlerTag` is the service tag for a `SuspenseFailureHandler`, which
124
- maps a failed suspense boundary to a `SuspenseFailureSubstitute` (fallback markup)
125
- during streaming SSR.
260
+ `SuspenseFailureHandlerTag` is the service tag for a `SuspenseFailureHandler`. The
261
+ handler maps a failed suspense boundary to a `SuspenseFailureSubstitute` (fallback
262
+ markup) during streaming SSR.
126
263
 
127
264
  ## Package root (`@weftui/dom`)
128
265
 
129
266
  Re-exports the renderer error types:
130
267
 
131
- - `HydrationMismatchError` the client tree did not match the server markup.
132
- - `UnsupportedNodeTypeError` a node type the renderer cannot handle was encountered.
133
- - `RenderError` a general rendering failure.
134
- - `StreamSubscriptionError` a reactive stream backing the tree failed to subscribe.
268
+ - `HydrationMismatchError`: the client tree did not match the server markup.
269
+ - `UnsupportedNodeTypeError`: a node type the renderer cannot handle was encountered.
270
+ - `RenderError`: a general rendering failure.
271
+ - `StreamSubscriptionError`: a reactive stream backing the tree failed to subscribe.
272
+
273
+ Also re-exports the `Props` namespace, below.
274
+
275
+ ## `Props`
276
+
277
+ `export * as Props from "@weftui/dom"`. Two functions for reconciling DOM prop
278
+ bags: `merge` combines multiple bags into one, `cx` builds a class string.
279
+ Both are pure and synchronous; neither subscribes anything. A reactive result
280
+ is a `Stream` description, subscribed later by the renderer in the element's
281
+ scope.
282
+
283
+ ### `Props.merge`
284
+
285
+ ```ts
286
+ function merge<const Bags extends ReadonlyArray<DomProps>>(...bags: Bags): Merged<Bags>;
287
+ ```
288
+
289
+ `DomProps` is `object`. `merge` is variadic and left-to-right: `merge()` is
290
+ `{}`, `merge(a)` is observationally `a`, and `merge(a, b, c)` folds pairwise
291
+ (`merge(merge(a, b), c)`). `{}` is the identity on either side.
292
+
293
+ The fold is associative per key, with one exception: `style` is not
294
+ associative when a non-object form (a string, or a whole-object stream) takes
295
+ part. See the `style` rule below.
296
+
297
+ Keys present on only one side pass through unchanged, by reference. For a key
298
+ present on both sides, the merged value depends on the key:
299
+
300
+ | Key | Rule | Result |
301
+ | --------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------ |
302
+ | `on*` (event handler, per the renderer's `on` + lowercase-third-char check) | chained: both handler bodies run, left then right | new handler function |
303
+ | `class` | concatenated | `string` if both sides are static, else a derived `Stream<string>` |
304
+ | `style` | object sides merge per property; any other form is last-wins | plain object, or the right side as-is |
305
+ | `ref` | fanned out | readonly array of `SubscriptionRef`s |
306
+ | anything else | last-wins | the right side's value, as-is |
307
+
308
+ **Handlers.** Both handler bodies run synchronously when the merged handler
309
+ is invoked, left then right, before either side's returned `Effect` is
310
+ awaited. This is what makes `event.preventDefault()` written in either body
311
+ observable to the other: two separate DOM listeners would both run during
312
+ dispatch, so neither can wait on the other's `Effect` to decide whether the
313
+ default action should still happen.
314
+
315
+ Only the returned Effects are sequenced: left first, then right. Both always
316
+ run regardless of whether the other fails. A plain void-returning handler is
317
+ lifted to `Effect.void` (or a died `Effect` if it throws). The merged
318
+ handler's error channel is the union `E_left | E_right`; if both sides fail,
319
+ both causes are aggregated into one, not deduplicated (two equal-looking
320
+ failures are still two failures).
321
+
322
+ `null` or `undefined` on either side means "not provided": the other side
323
+ passes through unchanged, whatever shape it has, including a reactive
324
+ `Stream`/`Effect`-of-handler value (not chained, since only two plain
325
+ functions are chained; see [Accepted limitations](#accepted-limitations)
326
+ below). `false` on the **right** means "explicitly disabled" and wins, since
327
+ the renderer reads `false` as "no handler." That is how a caller switches a
328
+ behavior's handler off. A `false` on the left simply loses to the right side,
329
+ like any other last-wins value.
330
+ This is the only place `merge` treats a nullish value specially. The generic
331
+ rule below explains why every other key does not.
332
+
333
+ **`class`.** Two static strings concatenate with a single space, no dedupe:
334
+ `merge({ class: "a" }, { class: "b" }).class === "a b"`. If either side is
335
+ reactive (`Stream`, `Effect`, or `Subscribable`), the result is a derived
336
+ `Stream<string>` combining the latest value from each side, space-joined. A
337
+ static side contributes immediately; the first emission waits only on the
338
+ reactive side(s) (await-first). A reactive side that ends without ever
339
+ emitting fails the derived stream with `NoPropValue`, which joins the merged
340
+ `E` channel. When both sides contribute nothing (absent, `undefined`, or
341
+ empty), the result is `""`, matching `cx` and clsx: it is not normalized to
342
+ `undefined`.
343
+
344
+ The `class` rule for two present sides is exactly `cx(left, right)`; `cx` is
345
+ that same engine exposed directly (see below).
346
+
347
+ **`style`.** Two per-property objects (`style: { color: "red" }`) merge by
348
+ key union, right side winning per key; each surviving value, static or
349
+ `Source`, passes through by reference. Any other shape on either side (a
350
+ `string`, or a whole-object stream) is last-wins: the right side replaces the
351
+ left entirely. This is the one case where merge is not associative, since
352
+ last-wins discards a side instead of combining it, so grouping the fold
353
+ differently changes the result. Upgrading whole-object-stream style merging
354
+ to a real per-key merge is additive future work, not a breaking change.
355
+
356
+ **`ref`.** Both sides concatenate into one readonly array, flattening any
357
+ side that is already an array, so associativity holds:
358
+ `merge({ ref: [a, b] }, { ref: c }).ref` is `[a, b, c]`. Nullish sides are
359
+ dropped, so an optional ref forwarded as `undefined` never enters the array.
360
+ Each ref keeps the normal per-ref contract: set once to `Some(element)` when
361
+ the element mounts. The renderer's `ref` prop accepts a `SubscriptionRef` or
362
+ a `readonly SubscriptionRef[]` directly, so `h.div({ ref: [a, b] })` fans out
363
+ without `merge` too.
364
+
365
+ **Everything else (generic keys).** Plain last-wins, matching object spread:
366
+ the right side's value wins as-is, including an explicit `undefined`. There
367
+ is no nullish guard on this arm (unlike the handler rule): a guard was tried
368
+ and reverted, because it made the runtime return the left value while the
369
+ type still said the right value was present, silently dropping the left
370
+ side's `E`/`R` channels from the merged type.
371
+
372
+ #### Type-layer contract
373
+
374
+ `Merged<Bags>`'s **value** types stay coarse (Source-shaped, not narrowed to
375
+ exactly what the runtime returns); its `E`/`R` **channels** stay precise,
376
+ because `PropsE`/`PropsR` (the machinery that feeds a merged bag's channels
377
+ into `h.*`'s resulting `Node<E, R>`) match `P[K]` against an exact `Stream`
378
+ or function shape. A looser value type would fail that match and silently
379
+ drop the channel.
380
+
381
+ Consequences worth knowing:
382
+
383
+ - A shared key's merged value type is **required**, even when the key is
384
+ optional on both input bags and absent from one side at runtime. Typing it
385
+ optional would fail the `PropsE`/`PropsR` match and drop the channel for
386
+ the common case: a behavior primitive's bag with optional props.
387
+ - A handler cell types as callable whenever _either_ side can carry a
388
+ handler, even if that side is `null` at runtime. Narrowing this would
389
+ require unioning the nullish outcome back in, which fails the same match.
390
+ - The `ref`-array cell types as `SubscriptionRef<Option<any>>`, not narrowed
391
+ to a specific element type. `SubscriptionRef` is invariant in its value
392
+ type, so a precise union would reject the headline case: fanning a
393
+ behavior's `SubscriptionRef<Option<HTMLElement>>` out alongside a caller's
394
+ `SubscriptionRef<Option<HTMLInputElement>>`. A mistyped ref inside a
395
+ fan-out array is therefore not caught at compile time; the set-once
396
+ contract keeps reads sound regardless.
397
+ - A bag typed with core's `HTMLAttributes`/`DOMAttributes` gets `unknown`
398
+ handler channels, because those types declare handlers as returning
399
+ `void | Effect<void, unknown, unknown>`. A behavior primitive that
400
+ declares precise handler signatures keeps precise channels through the
401
+ merge.
402
+
403
+ #### Accepted limitations
404
+
405
+ - Reactive handler _values_ (a `Stream`/`Effect` of a handler function, the
406
+ form core's `EventHandler` union allows) are not chained: any non-function
407
+ handler side falls back to last-wins, consistent with the whole-object
408
+ style rule. Their `E`/`R` channels are still collected in the merged type.
409
+ - An inline handler written directly inside a `merge` call gets no
410
+ contextual type for its event parameter, because `DomProps` is `object`
411
+ and `merge` cannot know which element it will end up on. Write
412
+ `onclick: (ev: MouseEvent) => …` with an explicit annotation, or give the
413
+ bag its own type.
414
+
415
+ ### `Props.cx`
416
+
417
+ ```ts
418
+ function cx<const Inputs extends ReadonlyArray<CxInput>>(...inputs: Inputs): CxResult<Inputs>;
419
+
420
+ type CxInput =
421
+ | string
422
+ | false
423
+ | null
424
+ | undefined
425
+ | Stream.Stream<string, any, any>
426
+ | Effect.Effect<string, any, any>
427
+ | Subscribable.Subscribable<string, any, any>
428
+ | CxRecord
429
+ | ReadonlyArray<CxInput>;
430
+
431
+ interface CxRecord {
432
+ readonly [className: string]:
433
+ | boolean
434
+ | Stream.Stream<boolean, any, any>
435
+ | Effect.Effect<boolean, any, any>
436
+ | Subscribable.Subscribable<boolean, any, any>;
437
+ }
438
+ ```
439
+
440
+ A reactive class-name builder, clsx-compatible plus reactive conditions. Each
441
+ input is one of:
442
+
443
+ - a **string**: kept as a literal class name segment;
444
+ - a **falsy value** (`false`, `null`, `undefined`, `""`): skipped;
445
+ - a **nested array** of `CxInput`: flattened recursively;
446
+ - a **record** (`{ className: condition }`): each key is included when its
447
+ condition is truthy;
448
+ - a **reactive value** in place of a string (a `Source<string>`), or as a
449
+ record condition (a `Source<boolean>`).
450
+
451
+ `cx()` is `""`. All-static inputs join into a plain `string`, space-separated,
452
+ no dedupe, no empty segments. Any reactive input (a reactive value, or a
453
+ reactive condition in a record) derives a `Stream<string>` that recomputes
454
+ the full class string on any emission, combining the latest value from every
455
+ reactive input.
456
+
457
+ A reactive value that resolves to `""` contributes nothing to that emission.
458
+ A reactive input that ends without ever emitting fails the stream with
459
+ `NoPropValue`, which joins the result's `E` channel along with every
460
+ reactive input's own `E`/`R`.
461
+
462
+ Only a plain record (an object literal, not a class instance, `Date`, or
463
+ boxed value like `SubscriptionRef`) is read as a condition map; anything else
464
+ is ignored rather than risking a foreign field name leaking in as a class
465
+ name.
466
+
467
+ `merge`'s `class` rule for two present sides is observationally
468
+ `cx(left, right)`: one engine behind both names.
135
469
 
136
470
  ## See also
137
471
 
138
- - [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server) a narrative walkthrough of the server/client split
139
- - [Provide Services](https://weftui.dev/docs/how-to/provide-services) recipes for value layers, `mountScoped`, and `ManagedRuntime`
140
- - [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) hydrate-in-place and why there is no virtual DOM
141
- - [Services and Context](https://weftui.dev/docs/explanation/services-and-context#layer-lifetime-at-the-mount) why scoped layers need the mount to outlive initial render
472
+ - [Compose Behavior and Markup](https://weftui.dev/docs/how-to/compose-behavior-and-markup): using `Props.merge`/`Props.cx` to combine a behavior's props with the caller's
473
+ - [Style Reactively](https://weftui.dev/docs/how-to/style-reactively): the `style` prop's reactive forms
474
+ - [Use Element Refs](https://weftui.dev/docs/how-to/use-element-refs): the `ref` prop and its fan-out form
475
+ - [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server): a narrative walkthrough of the server/client split
476
+ - [Provide Services](https://weftui.dev/docs/how-to/provide-services): recipes for app layers, scoped layers, and binding an app's lifetime to an external scope
477
+ - [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model): hydrate-in-place and why there is no virtual DOM
478
+ - [Services and Context](https://weftui.dev/docs/explanation/services-and-context): how services flow from the app layer to every root
142
479
  - [`@weftui/core` reference](https://weftui.dev/docs/reference/core) · [`@weftui/router` reference](https://weftui.dev/docs/reference/router)