@weftui/router 0.23.3 → 0.25.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.
@@ -1,5 +1,5 @@
1
- import { S as Router, b as NavState, i as RouterDef, m as RouteNode, u as Fields, x as NavigateOptions } from "../compile-HOyeyWRy.js";
2
- import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-DbqTgkXa.js";
1
+ import { S as Router, b as NavState, i as RouterDef, m as RouteNode, u as Fields, x as NavigateOptions } from "../compile-CQhAXB5f.js";
2
+ import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-CGfLQgZS.js";
3
3
  import { Effect, Layer, Scope } from "effect";
4
4
  import { AppRpcClientTag } from "@weftui/core";
5
5
  import { RpcGroup } from "@effect/rpc";
@@ -1,7 +1,7 @@
1
- import { i as getPreload, n as outletNode, r as Router, t as RouterApp } from "../outlet-CXgDkheJ.js";
2
- import { r as match, t as href } from "../href-CSRbOQov.js";
1
+ import { a as Router, i as setResolvedCommit, n as outletNode, o as getPreload, r as preRunLeaf, t as RouterApp } from "../outlet-PoLEThsy.js";
2
+ import { r as match, t as href } from "../href-Deoi59OI.js";
3
3
  import { FetchHttpClient, HttpApiClient } from "@effect/platform";
4
- import { Context, Effect, Layer, Option, Runtime, Schema, Stream, Subscribable, SubscriptionRef } from "effect";
4
+ import { Context, Effect, Exit, Fiber, Layer, Option, Runtime, Schema, Scope, Stream, Subscribable, SubscriptionRef } from "effect";
5
5
  import { AppRpcClientTag } from "@weftui/core";
6
6
  //#region src/client/link.ts
7
7
  /**
@@ -82,28 +82,64 @@ function RouterLive(def, options = {}) {
82
82
  const runtime = yield* Effect.runtime();
83
83
  const httpApiClient = yield* HttpApiClient.make(def.httpApi, { baseUrl: options.baseUrl ?? window.location.origin }).pipe(Effect.provide(FetchHttpClient.layer));
84
84
  let latest = 0;
85
+ let inflightPreRun;
86
+ let committedScope;
87
+ let router;
85
88
  const collectPreloads = (m) => m._tag !== "Matched" ? [] : [m.leaf.component, ...m.leaf.layoutChain.map((l) => l.component)].map(getPreload).filter((p) => p !== void 0);
86
89
  const commitUrl = (normalized, replace) => Effect.sync(() => {
87
90
  if (replace) window.history.replaceState(null, "", normalized);
88
91
  else window.history.pushState(null, "", normalized);
89
92
  });
90
93
  const commitTo = (normalized, pushUrl, replace) => Effect.gen(function* () {
91
- const preloads = collectPreloads(match(def, normalized));
92
- if (preloads.length === 0) {
93
- if (pushUrl) yield* commitUrl(normalized, replace);
94
- yield* SubscriptionRef.set(urlRef, normalized);
95
- return;
96
- }
94
+ const target = match(def, normalized);
97
95
  const token = ++latest;
98
- yield* SubscriptionRef.set(navRef, {
99
- _tag: "Navigating",
100
- to: normalized
96
+ let emitted = false;
97
+ const stale = inflightPreRun;
98
+ inflightPreRun = void 0;
99
+ if (stale !== void 0) yield* Fiber.interrupt(stale);
100
+ const preloads = collectPreloads(target);
101
+ if (preloads.length > 0) {
102
+ emitted = true;
103
+ yield* SubscriptionRef.set(navRef, {
104
+ _tag: "Navigating",
105
+ to: normalized
106
+ });
107
+ yield* Effect.promise(() => Promise.all(preloads.map((p) => p()))).pipe(Effect.onError(() => token === latest ? SubscriptionRef.set(navRef, { _tag: "Idle" }) : Effect.void));
108
+ if (token !== latest) return;
109
+ }
110
+ let exit;
111
+ if (target._tag === "Matched") {
112
+ const scope = yield* Scope.make();
113
+ const fiber = yield* Effect.fork(Effect.provideService(preRunLeaf(router, target), Scope.Scope, scope));
114
+ inflightPreRun = fiber;
115
+ if (!emitted) yield* Effect.fork(Effect.gen(function* () {
116
+ const done = yield* Fiber.poll(fiber);
117
+ if (Option.isNone(done) && token === latest) {
118
+ emitted = true;
119
+ yield* SubscriptionRef.set(navRef, {
120
+ _tag: "Navigating",
121
+ to: normalized
122
+ });
123
+ }
124
+ }));
125
+ const joined = yield* Effect.exit(Fiber.join(fiber));
126
+ if (inflightPreRun === fiber) inflightPreRun = void 0;
127
+ if (token !== latest || Exit.isFailure(joined)) {
128
+ yield* Scope.close(scope, Exit.void);
129
+ return;
130
+ }
131
+ exit = joined.value;
132
+ const previous = committedScope;
133
+ committedScope = scope;
134
+ if (previous !== void 0) yield* Scope.close(previous, Exit.void);
135
+ }
136
+ if (exit !== void 0) setResolvedCommit(router, {
137
+ url: normalized,
138
+ exit
101
139
  });
102
- yield* Effect.promise(() => Promise.all(preloads.map((p) => p()))).pipe(Effect.onError(() => token === latest ? SubscriptionRef.set(navRef, { _tag: "Idle" }) : Effect.void));
103
- if (token !== latest) return;
104
140
  if (pushUrl) yield* commitUrl(normalized, replace);
105
141
  yield* SubscriptionRef.set(urlRef, normalized);
106
- yield* SubscriptionRef.set(navRef, { _tag: "Idle" });
142
+ if (emitted) yield* SubscriptionRef.set(navRef, { _tag: "Idle" });
107
143
  });
108
144
  const navigate = (to, options) => commitTo(normalizeTo(to), true, options?.replace === true);
109
145
  const onPopState = () => {
@@ -124,7 +160,7 @@ function RouterLive(def, options = {}) {
124
160
  const flatClient = yield* RpcClient.make(rpc.group, { flatten: true }).pipe(Effect.provide(RpcClient.layerProtocolHttp({ url: `${baseUrl}${RPC_PATH}` }).pipe(Layer.provide(Layer.mergeAll(FetchHttpClient.layer, RpcSerialization.layerJson)))));
125
161
  appRpcClient = AppRpcClientTag.of({ call: (tag, payload) => flatClient(tag, payload) });
126
162
  }
127
- const router = Router.of({
163
+ router = Router.of({
128
164
  currentMatch,
129
165
  navigate,
130
166
  httpApiClient: Option.some(httpApiClient),
@@ -133,7 +169,7 @@ function RouterLive(def, options = {}) {
133
169
  return Context.make(Router, router).pipe(Context.add(AppRpcClientTag, appRpcClient));
134
170
  }));
135
171
  const context = options.context;
136
- return context === void 0 ? core : Layer.merge(core, context);
172
+ return context === void 0 ? core : Layer.merge(Layer.provide(core, context), context);
137
173
  }
138
174
  //#endregion
139
175
  //#region src/client/navigation.ts
@@ -122,11 +122,13 @@ declare function match(def: RouterDef, url: string): RouteMatch;
122
122
  */
123
123
  type RouterHttpApiClient = HttpApiClient.Client<any, any, never>;
124
124
  /**
125
- * The client navigation state exposed reactively as {@link Router.navigating}. A
126
- * navigation to a `Router.lazy` route is **deferred-commit** (see
127
- * `pending-navigation.specs.md`): while its chunk resolves the state is
128
- * `Navigating` (carrying the target `to`), returning to `Idle` on commit. An
129
- * eager-route navigation stays `Idle`. Always `Idle` on the server (buffered render).
125
+ * The client navigation state exposed reactively as {@link Router.navigating}.
126
+ * Client navigation is **deferred-commit** (see `pending-navigation.specs.md`
127
+ * and `resolve-before-commit.specs.md`): while the target branch's lazy chunk(s)
128
+ * and the leaf component's own effect (its data) resolve, the state is
129
+ * `Navigating` (carrying the target `to`), returning to `Idle` on commit. A
130
+ * navigation whose leaf resolves synchronously never leaves `Idle`. Always
131
+ * `Idle` on the server (buffered render).
130
132
  */
131
133
  type NavState = {
132
134
  readonly _tag: "Idle";
@@ -160,9 +162,10 @@ declare const Router_base: Context.TagClass<Router, "@weftui/router/Router", {
160
162
  readonly httpApiClient: Option.Option<RouterHttpApiClient>;
161
163
  /**
162
164
  * Reactive client navigation state (see {@link NavState}). `Navigating{to}` while
163
- * a deferred-commit navigation resolves its lazy chunk(s), `Idle` otherwise. An
164
- * app reads it via {@link Router.navigatingStream} to render pending UI (e.g. a
165
- * top progress bar). Constant `Idle` on the server.
165
+ * a deferred-commit navigation resolves its lazy chunk(s) and the leaf
166
+ * component's own effect, `Idle` otherwise. An app reads it via
167
+ * {@link Router.navigatingStream} to render pending UI (e.g. a top progress
168
+ * bar). Constant `Idle` on the server.
166
169
  */
167
170
  readonly navigating: Subscribable.Subscribable<NavState>;
168
171
  }>;
@@ -1,4 +1,4 @@
1
- import { s as leafRegistry } from "./outlet-CXgDkheJ.js";
1
+ import { l as leafRegistry } from "./outlet-PoLEThsy.js";
2
2
  import { Either, Option, Schema } from "effect";
3
3
  //#region src/matcher.ts
4
4
  /** Escapes a literal path segment for inclusion in a `RegExp`. */
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { A as notFound, C as RouterHttpApiClient, D as RouterNotFound, E as match, O as RouterParamsError, S as Router, T as compileMatchers, _ as TreeE, a as RouterOptions, b as NavState, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as isRouterNotFound, l as ComponentSlot, m as RouteNode, n as CompiledLayout, o as buildHttpApi, p as RouteHandlerProps, r as CompiledLeaf, s as compile, t as Compiled, u as Fields, v as TreeNode, w as RouteMatch, x as NavigateOptions, y as TreeR } from "./compile-HOyeyWRy.js";
2
- import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-DbqTgkXa.js";
1
+ import { A as notFound, C as RouterHttpApiClient, D as RouterNotFound, E as match, O as RouterParamsError, S as Router, T as compileMatchers, _ as TreeE, a as RouterOptions, b as NavState, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as isRouterNotFound, l as ComponentSlot, m as RouteNode, n as CompiledLayout, o as buildHttpApi, p as RouteHandlerProps, r as CompiledLeaf, s as compile, t as Compiled, u as Fields, v as TreeNode, w as RouteMatch, x as NavigateOptions, y as TreeR } from "./compile-CQhAXB5f.js";
2
+ import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-CGfLQgZS.js";
3
3
  export { type Compiled, type CompiledLayout, type CompiledLeaf, type ComponentSlot, type Fields, type FieldsType, type HrefArgs, type LayoutNode, type NavState, type NavigateOptions, type RouteHandlerProps, type RouteMatch, type RouteNode, Router, RouterApp, type RouterDef, type RouterHttpApiClient, RouterNotFound, type RouterOptions, RouterParamsError, type SubtreeE, type SubtreeR, type TreeE, type TreeNode, type TreeR, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { a as buildHttpApi, c as RouterNotFound, d as notFound, l as RouterParamsError, n as outletNode, o as compile, r as Router, s as leafRegistry, t as RouterApp, u as isRouterNotFound } from "./outlet-CXgDkheJ.js";
2
- import { n as compileMatchers, r as match, t as href } from "./href-CSRbOQov.js";
1
+ import { a as Router, c as compile, d as RouterParamsError, f as isRouterNotFound, l as leafRegistry, n as outletNode, p as notFound, s as buildHttpApi, t as RouterApp, u as RouterNotFound } from "./outlet-PoLEThsy.js";
2
+ import { n as compileMatchers, r as match, t as href } from "./href-Deoi59OI.js";
3
3
  export { Router, RouterApp, RouterNotFound, RouterParamsError, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
@@ -1,4 +1,4 @@
1
- import { D as RouterNotFound, S as Router, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields } from "./compile-HOyeyWRy.js";
1
+ import { D as RouterNotFound, S as Router, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields } from "./compile-CQhAXB5f.js";
2
2
  import { Node } from "@weftui/core";
3
3
 
4
4
  //#region src/href.d.ts
@@ -1,6 +1,6 @@
1
1
  import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "@effect/platform";
2
- import { Context, Effect, Schema, Stream, Subscribable, pipe } from "effect";
3
- import { Boundary, h } from "@weftui/core";
2
+ import { Context, Effect, Exit, Schema, Stream, Subscribable, pipe } from "effect";
3
+ import { Boundary, getElementDescriptor, h } from "@weftui/core";
4
4
  //#region src/errors.ts
5
5
  /**
6
6
  * Tagged error raised by {@link notFound} and caught by the router's internal
@@ -374,8 +374,8 @@ function subscribeQuery(fields) {
374
374
  /**
375
375
  * Reactive {@link Subscribable} of the client {@link NavState}. A component reads
376
376
  * `[(yield* Router.navigatingStream).changes]` to render pending UI during a
377
- * deferred-commit navigation (`pending-navigation.specs.md`). Re-exported as
378
- * `Router.navigatingStream`.
377
+ * deferred-commit navigation (`pending-navigation.specs.md`,
378
+ * `resolve-before-commit.specs.md`). Re-exported as `Router.navigatingStream`.
379
379
  */
380
380
  const subscribeNavigating = Effect.map(Router, (router) => router.navigating);
381
381
  (function(_Router) {
@@ -391,6 +391,94 @@ const subscribeNavigating = Effect.map(Router, (router) => router.navigating);
391
391
  _Router.navigatingStream = subscribeNavigating;
392
392
  })(Router || (Router = {}));
393
393
  //#endregion
394
+ //#region src/resolved-commit.ts
395
+ /**
396
+ * Internal seams for resolve-before-commit navigation — the resolved-commit
397
+ * stash and the staged match view. See `resolve-before-commit.specs.md`.
398
+ *
399
+ * **Not public API.** This module is deliberately not re-exported from
400
+ * `src/index.ts` (mirroring the internality of `PreloadSlot` in `route-tree.ts`):
401
+ * it is consumed by the client `navigate` (`client/router-live.ts`), which
402
+ * pre-runs the matched leaf's component effect and stashes its `Exit`, and by
403
+ * the outlet's leaf render (`outlet.ts`), which consumes the stash to swap in
404
+ * the already-resolved node synchronously.
405
+ *
406
+ * The server router and the hydrate path never touch this module's state: the
407
+ * stash is only ever written by a client navigation, so `renderLevel` falls
408
+ * through to the ordinary slot invocation everywhere else (AC-R13).
409
+ */
410
+ /**
411
+ * Brand key under which the client `Router` service instance carries its
412
+ * mutable resolved-commit stash. Internal to `@weftui/router`; declared
413
+ * `unique symbol` so it is usable as a computed interface key.
414
+ */
415
+ const ResolvedCommit = Symbol.for("@weftui/router/resolved-commit");
416
+ /**
417
+ * Writes the stash on the (client) router instance: called by `navigate` /
418
+ * popstate with the pre-run's `Exit`, immediately before the URL ref is set,
419
+ * so the outlet emission triggered by that commit finds it (AC-R1/AC-R2).
420
+ * Overwrites any stale entry — latest-wins already guarantees only the newest
421
+ * navigation reaches the commit step (AC-R6).
422
+ */
423
+ function setResolvedCommit(router, entry) {
424
+ router[ResolvedCommit] = entry;
425
+ }
426
+ /**
427
+ * Consumes the stash: returns the entry when its `url` equals the emission's
428
+ * `match.url` and **clears the slot** (consume-exactly-once, AC-R2), else
429
+ * `undefined` — a URL mismatch (stale entry) or an absent slot (server render,
430
+ * hydration, non-navigation re-emission) falls through to the ordinary slot
431
+ * invocation in `renderLevel`.
432
+ */
433
+ function takeResolvedCommit(router, url) {
434
+ const slot = router;
435
+ const entry = slot[ResolvedCommit];
436
+ if (entry === void 0 || entry.url !== url) return;
437
+ slot[ResolvedCommit] = void 0;
438
+ return entry;
439
+ }
440
+ /**
441
+ * The staged `Router` view the pre-run executes under (AC-R4): identical to
442
+ * `router` except `currentMatch.get` resolves to the **target** match — the
443
+ * URL ref has not moved yet, and one-shot reads (`Router.params`,
444
+ * `Router.query`, `currentMatch.get`) inside the pre-running component body
445
+ * must decode the destination, not the page being left. `currentMatch.changes`
446
+ * (and `navigate` / `httpApiClient` / `navigating`) delegate to the live
447
+ * service, so reactive subscriptions — which occur at render/mount time,
448
+ * post-commit — observe the committed match onward.
449
+ */
450
+ function stageMatch(router, target) {
451
+ return {
452
+ ...router,
453
+ currentMatch: Subscribable.make({
454
+ get: Effect.succeed(target),
455
+ changes: router.currentMatch.changes
456
+ })
457
+ };
458
+ }
459
+ /**
460
+ * Pre-runs a matched leaf's component effect: invokes the slot with the
461
+ * target match's handler-arg props (`{ path, query }` — exactly what
462
+ * `renderLevel` passes), under the {@link stageMatch | staged view}, and
463
+ * captures the outcome as an `Exit` (AC-R1/AC-R7). The returned effect never
464
+ * fails — failures are folded into the `Exit` for stash-and-replay — but it
465
+ * is **interruptible**: a superseding navigation interrupts the whole pre-run
466
+ * fiber (AC-R6). Requires the caller's runtime context (the `RouterLive`
467
+ * layer's — `Router`, `AppRpcClientTag`, app services), which is what makes
468
+ * the pre-run possible at all (Feasibility §1).
469
+ */
470
+ function preRunLeaf(router, target) {
471
+ return Effect.suspend(() => {
472
+ if (target._tag !== "Matched") return Effect.succeed(Exit.succeed(null));
473
+ const node = target.leaf.component({
474
+ path: target.path,
475
+ query: target.query
476
+ });
477
+ if (!Effect.isEffect(node) || getElementDescriptor(node) !== void 0) return Effect.succeed(Exit.succeed(node));
478
+ return Effect.exit(Effect.provideService(node, Router, stageMatch(router, target)));
479
+ });
480
+ }
481
+ //#endregion
394
482
  //#region src/outlet.ts
395
483
  /** Substitutes `:name` placeholders in a pattern with encoded param values. */
396
484
  function substitute(pattern, params) {
@@ -404,17 +492,31 @@ function keyOf(index, match) {
404
492
  const layout = chain[index];
405
493
  return layout === void 0 ? `layout:${index}` : substitute(layout.patternPrefix, match.path);
406
494
  }
495
+ /**
496
+ * Renders the leaf of a `Matched` match. A client navigation stashes the leaf's
497
+ * pre-run `Exit` on the router instance (`resolve-before-commit.specs.md`): a
498
+ * matching stash is consumed exactly once and its Success node returned
499
+ * **synchronously** — the atomic swap (AC-R2) — while a Failure replays through
500
+ * the normal render error path without re-running the component (AC-R7). With no
501
+ * stash (server render, hydration, non-navigation re-emission) the slot is
502
+ * invoked with the live match's decoded path/query as handler-arg props
503
+ * (`RouteHandlerProps`); Component / zero-arg slots simply ignore them.
504
+ */
505
+ function renderLeaf(router, match) {
506
+ const entry = takeResolvedCommit(router, match.url);
507
+ if (entry !== void 0) return Exit.isSuccess(entry.exit) ? entry.exit.value : Effect.failCause(entry.exit.cause);
508
+ return match.leaf.component({
509
+ path: match.path,
510
+ query: match.query
511
+ });
512
+ }
407
513
  /** Builds the `Renderable` for one nesting level of a match. */
408
514
  function renderLevel(def, router, index, match) {
409
515
  if (match._tag === "NotFound") return def.compiled.notFound();
410
516
  const chain = match.leaf.layoutChain;
411
- const leafProps = {
412
- path: match.path,
413
- query: match.query
414
- };
415
- if (index >= chain.length) return match.leaf.component(leafProps);
517
+ if (index >= chain.length) return renderLeaf(router, match);
416
518
  const layout = chain[index];
417
- if (layout === void 0) return match.leaf.component(leafProps);
519
+ if (layout === void 0) return renderLeaf(router, match);
418
520
  const outlet = h.fragment([levelStream(def, router, index + 1)]);
419
521
  return Effect.provideService(layout.component({}), Router.Outlet, outlet);
420
522
  }
@@ -464,4 +566,4 @@ function RouterApp(def) {
464
566
  }, [outletNode(def)]);
465
567
  }
466
568
  //#endregion
467
- export { buildHttpApi as a, RouterNotFound as c, notFound as d, getPreload as i, RouterParamsError as l, outletNode as n, compile as o, Router as r, leafRegistry as s, RouterApp as t, isRouterNotFound as u };
569
+ export { Router as a, compile as c, RouterParamsError as d, isRouterNotFound as f, setResolvedCommit as i, leafRegistry as l, outletNode as n, getPreload as o, notFound as p, preRunLeaf as r, buildHttpApi as s, RouterApp as t, RouterNotFound as u };
@@ -1,4 +1,4 @@
1
- import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-HOyeyWRy.js";
1
+ import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-CQhAXB5f.js";
2
2
  import { Effect, Layer } from "effect";
3
3
  import { AppRpcClientTag } from "@weftui/core";
4
4
  import { RpcGroup } from "@effect/rpc";
@@ -1,4 +1,4 @@
1
- import { c as RouterNotFound, n as outletNode, r as Router, u as isRouterNotFound } from "../outlet-CXgDkheJ.js";
1
+ import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-PoLEThsy.js";
2
2
  import { HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpServer, HttpServerResponse } from "@effect/platform";
3
3
  import { Cause, Effect, Exit, Layer, Option, Schema, Scope, Stream, Subscribable } from "effect";
4
4
  import { AppRpcClientTag } from "@weftui/core";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weftui/router",
3
- "version": "0.23.3",
3
+ "version": "0.25.0",
4
4
  "description": "Universal nested router for Weft",
5
5
  "license": "MIT",
6
6
  "author": "Stef van Wijchen",
@@ -32,8 +32,8 @@
32
32
  "dependencies": {
33
33
  "@effect/platform": "^0.96.1",
34
34
  "@effect/rpc": "^0.75.1",
35
- "@weftui/core": "0.23.3",
36
- "@weftui/dom": "0.23.3"
35
+ "@weftui/core": "0.25.0",
36
+ "@weftui/dom": "0.25.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/jsdom": "^28.0.3",