@effectweb/tanstack-router 0.8.0 → 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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ EffectWeb adapter for TanStack Router Core.
6
6
  - `RootRoute` and `Route` expose TanStack's route constructors: matching, params, validated search, redirects, loaders and errors remain in the core.
7
7
  - `mountRouter(router)` owns the history subscription in an Effect scope and returns an EffectWeb `Source` of router state.
8
8
  - The store adapter batches publications and copies plain state before EffectWeb freezes it. It never freezes TanStack's internal match records.
9
- - The host program subscribes to that Source and renders its own matching views. Scope cleanup releases the Source, subscriptions and owned history.
9
+ - The host program subscribes to that Source and renders its own matching views. Scope cleanup interrupts loaders, before-load hooks and preloads, settles pending navigation, and releases the Source, subscriptions and owned history. Mount each router only once.
10
10
  - `createLink(router)` returns a typed `<Link>`: `to`, `params` and `search` are checked against the route tree. It renders a real `<a href>` (middle-click, copy link and screen readers keep working) and navigates in place on plain left clicks. Pass `active` from your route model to set `aria-current="page"`.
11
11
 
12
12
  ```tsx
@@ -20,3 +20,5 @@ const Link = createLink(router);
20
20
  Prefer `<Link>` to buttons that call `navigate(path: string)`: string paths lose route typing, and buttons are not links for keyboard, assistive technology or new-tab navigation.
21
21
 
22
22
  Currently there is no JSX Outlet API, route-level view/error boundary system, SSR adapter or file-route generator. Those can build on this binding without introducing a second route matcher.
23
+
24
+ Link preloading and automatic active matching are not exposed as link props. Call `router.preloadRoute(options)` explicitly when needed, and derive `active` from the route model.
package/dist/index.d.ts CHANGED
@@ -6,5 +6,5 @@ export { createBrowserHistory, createMemoryHistory } from '@tanstack/history';
6
6
  /** TanStack supplies matching, loaders, search validation and typed navigation; EffectWeb owns publications. */
7
7
  export declare const createRouter: CreateRouterFn;
8
8
  /** Mount once per router in an Effect scope. The returned Source can drive an EffectWeb program or view. */
9
- export declare const mountRouter: <Router extends AnyRouter>(router: Router) => Effect.Effect<Source<RouterState<Router["routeTree"]>>, Error, Scope.Scope>;
9
+ export declare const mountRouter: <Router extends AnyRouter>(router: Router) => Effect.Effect<Source<RouterState<Router['routeTree']>>, Error, Scope.Scope>;
10
10
  export { createLink, linkTarget, type LinkProps } from './link.js';
package/dist/index.js CHANGED
@@ -1,106 +1,150 @@
1
- import { Effect } from 'effect';
2
- import { projectionSource } from 'effectweb/advanced';
3
- import { RouterCore, createNonReactiveMutableStore, createNonReactiveReadonlyStore, } from '@tanstack/router-core';
4
- export { BaseRoute as Route, BaseRootRoute as RootRoute, redirect, notFound, } from '@tanstack/router-core';
5
- export { createBrowserHistory, createMemoryHistory } from '@tanstack/history';
6
- // EffectWeb freezes published plain data. Copy it so TanStack can keep managing its mutable match resources.
7
- const snapshotCopy = (value, seen = new WeakMap()) => {
8
- if (value === null || typeof value !== 'object')
9
- return value;
10
- const prototype = Object.getPrototypeOf(value);
11
- if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null)
12
- return value;
13
- if (seen.has(value))
14
- return seen.get(value);
15
- const copy = Array.isArray(value) ? [] : {};
16
- seen.set(value, copy);
17
- for (const key of Reflect.ownKeys(value)) {
18
- if (key === 'length' && Array.isArray(value))
19
- continue;
20
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
21
- if ('value' in descriptor)
22
- Object.defineProperty(copy, key, {
23
- value: snapshotCopy(descriptor.value, seen),
24
- writable: true,
25
- enumerable: descriptor.enumerable ?? false,
26
- configurable: true,
27
- });
28
- }
29
- return copy;
1
+ import { Effect } from "effect";
2
+ import { projectionSource } from "effectweb/advanced";
3
+ import {
4
+ RouterCore,
5
+ createNonReactiveMutableStore,
6
+ createNonReactiveReadonlyStore
7
+ } from "@tanstack/router-core";
8
+ import {
9
+ BaseRoute,
10
+ BaseRootRoute,
11
+ redirect,
12
+ notFound
13
+ } from "@tanstack/router-core";
14
+ import { createBrowserHistory, createMemoryHistory } from "@tanstack/history";
15
+ const snapshotCopy = (value, seen = /* @__PURE__ */ new WeakMap()) => {
16
+ if (value === null || typeof value !== "object") return value;
17
+ const prototype = Object.getPrototypeOf(value);
18
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return value;
19
+ if (seen.has(value)) return seen.get(value);
20
+ const copy = Array.isArray(value) ? [] : {};
21
+ seen.set(value, copy);
22
+ for (const key of Reflect.ownKeys(value)) {
23
+ if (key === "length" && Array.isArray(value)) continue;
24
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
25
+ if ("value" in descriptor)
26
+ Object.defineProperty(copy, key, {
27
+ value: snapshotCopy(descriptor.value, seen),
28
+ writable: true,
29
+ enumerable: descriptor.enumerable ?? false,
30
+ configurable: true
31
+ });
32
+ }
33
+ return copy;
30
34
  };
31
- const publications = new WeakMap();
32
- /** TanStack supplies matching, loaders, search validation and typed navigation; EffectWeb owns publications. */
33
- export const createRouter = (options) => {
34
- const listeners = new Set();
35
- let batchDepth = 0;
36
- let dirty = false;
37
- const publish = () => {
38
- dirty = true;
39
- if (batchDepth)
40
- return;
41
- dirty = false;
42
- for (const listener of listeners)
43
- listener();
44
- };
45
- const instance = new RouterCore(options, () => ({
46
- createMutableStore: (initial) => {
47
- const store = createNonReactiveMutableStore(initial);
48
- return {
49
- get: store.get,
50
- set: (next) => {
51
- store.set((previous) => typeof next === 'function' ? next(previous) : next);
52
- publish();
53
- },
54
- };
55
- },
56
- createReadonlyStore: createNonReactiveReadonlyStore,
57
- batch: (run) => {
58
- batchDepth++;
59
- try {
60
- run();
61
- }
62
- finally {
63
- batchDepth--;
64
- if (!batchDepth && dirty)
65
- publish();
66
- }
67
- },
68
- }));
69
- publications.set(instance, listeners);
70
- return instance;
35
+ const publications = /* @__PURE__ */ new WeakMap();
36
+ const closedRouters = /* @__PURE__ */ new WeakSet();
37
+ const createRouter = (options) => {
38
+ const listeners = /* @__PURE__ */ new Set();
39
+ let batchDepth = 0;
40
+ let dirty = false;
41
+ const publish = () => {
42
+ dirty = true;
43
+ if (batchDepth) return;
44
+ dirty = false;
45
+ for (const listener of listeners) listener();
46
+ };
47
+ const instance = new RouterCore(options, () => ({
48
+ createMutableStore: (initial) => {
49
+ const store = createNonReactiveMutableStore(initial);
50
+ return {
51
+ get: store.get,
52
+ set: (next) => {
53
+ store.set(
54
+ (previous) => typeof next === "function" ? next(previous) : next
55
+ );
56
+ publish();
57
+ }
58
+ };
59
+ },
60
+ createReadonlyStore: createNonReactiveReadonlyStore,
61
+ batch: (run) => {
62
+ batchDepth++;
63
+ try {
64
+ run();
65
+ } finally {
66
+ batchDepth--;
67
+ if (!batchDepth && dirty) publish();
68
+ }
69
+ }
70
+ }));
71
+ const load = instance.load;
72
+ const navigate = instance.navigate;
73
+ const preload = instance.preloadRoute;
74
+ const closed = () => new Error("Cannot use a disposed router");
75
+ instance.load = (...args) => closedRouters.has(instance) ? Promise.reject(closed()) : load(...args);
76
+ instance.navigate = (...args) => closedRouters.has(instance) ? Promise.reject(closed()) : navigate(...args);
77
+ instance.preloadRoute = (...args) => closedRouters.has(instance) ? Promise.reject(closed()) : preload(...args);
78
+ publications.set(instance, listeners);
79
+ return instance;
71
80
  };
72
- /** Mount once per router in an Effect scope. The returned Source can drive an EffectWeb program or view. */
73
- export const mountRouter = (router) => Effect.gen(function* () {
74
- const source = yield* Effect.acquireRelease(Effect.sync(() => {
75
- const listeners = publications.get(router);
76
- const history = router.history;
77
- if (!listeners)
78
- throw new Error('Use @effectweb/tanstack-router.createRouter before mountRouter');
79
- const source = projectionSource({
80
- project: () => snapshotCopy(router.state),
81
- });
82
- listeners.add(source.changed);
83
- source.start();
84
- const unsubscribe = history.subscribe(() => {
85
- void router
86
- .load()
87
- .catch((error) => console.error('Router navigation failed', error));
88
- });
89
- return {
90
- source,
91
- dispose: () => {
92
- unsubscribe();
93
- listeners.delete(source.changed);
94
- source.dispose();
95
- history.flush();
96
- history.destroy();
97
- },
98
- };
99
- }), (mounted) => Effect.sync(() => mounted.dispose()));
100
- yield* Effect.tryPromise({
101
- try: () => router.load(),
102
- catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
103
- });
104
- return source.source;
81
+ const mountedRouters = /* @__PURE__ */ new WeakSet();
82
+ function stopRouter(router) {
83
+ closedRouters.add(router);
84
+ const transaction = router._tx;
85
+ const preflight = router._preflight;
86
+ const flights = [...router._flights?.values() ?? []];
87
+ delete router._tx;
88
+ delete router._preflight;
89
+ router._flights?.clear();
90
+ if (router._pending?.[3] !== void 0) clearTimeout(router._pending[3]);
91
+ delete router._pending;
92
+ const rendered = router._rendered;
93
+ delete router._rendered;
94
+ rendered?.[1]?.(false);
95
+ preflight?.abort();
96
+ transaction?.[0].abort();
97
+ for (const flight of flights) flight[1].abort();
98
+ router.clearCache();
99
+ router._commitPromise?.resolve();
100
+ }
101
+ const mountRouter = (router) => Effect.gen(function* () {
102
+ const source = yield* Effect.acquireRelease(
103
+ Effect.sync(() => {
104
+ const listeners = publications.get(router);
105
+ const history = router.history;
106
+ if (!listeners)
107
+ throw new Error("Use @effectweb/tanstack-router.createRouter before mountRouter");
108
+ if (mountedRouters.has(router)) throw new Error("A router can only be mounted once");
109
+ mountedRouters.add(router);
110
+ const source2 = projectionSource({
111
+ project: () => snapshotCopy(router.state)
112
+ });
113
+ listeners.add(source2.changed);
114
+ source2.start();
115
+ const unsubscribe = history.subscribe(() => {
116
+ void router.load().catch((error) => console.error("Router navigation failed", error));
117
+ });
118
+ return {
119
+ source: source2,
120
+ dispose: () => {
121
+ unsubscribe();
122
+ listeners.delete(source2.changed);
123
+ source2.dispose();
124
+ stopRouter(router);
125
+ history.flush();
126
+ history.destroy();
127
+ }
128
+ };
129
+ }),
130
+ (mounted) => Effect.sync(() => mounted.dispose())
131
+ );
132
+ yield* Effect.tryPromise({
133
+ try: () => router.load(),
134
+ catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
135
+ });
136
+ return source.source;
105
137
  });
106
- export { createLink, linkTarget } from './link.js';
138
+ import { createLink, linkTarget } from "./link.js";
139
+ export {
140
+ BaseRootRoute as RootRoute,
141
+ BaseRoute as Route,
142
+ createBrowserHistory,
143
+ createLink,
144
+ createMemoryHistory,
145
+ createRouter,
146
+ linkTarget,
147
+ mountRouter,
148
+ notFound,
149
+ redirect
150
+ };
package/dist/link.d.ts CHANGED
@@ -1,15 +1,21 @@
1
1
  import { type AnyRouter, type LinkOptions } from '@tanstack/router-core';
2
2
  import type { JSX } from 'effectweb';
3
3
  import { jsxComponent } from 'effectweb/jsx';
4
+ type SupportedLinkOptions<R extends AnyRouter, F extends string, T extends string | undefined> = LinkOptions<R, F, T> & {
5
+ readonly preload?: never;
6
+ readonly preloadDelay?: never;
7
+ readonly preloadIntentProximity?: never;
8
+ readonly activeOptions?: never;
9
+ };
4
10
  type Anchor = Omit<JSX.IntrinsicElements['a'], 'href' | 'onClick' | 'target' | 'children'>;
5
- export type LinkProps<Router extends AnyRouter, From extends string = string, To extends string | undefined = '.'> = LinkOptions<Router, From, To> & Anchor & {
11
+ export type LinkProps<Router extends AnyRouter, From extends string = string, To extends string | undefined = '.'> = SupportedLinkOptions<Router, From, To> & Anchor & {
6
12
  /** Mark the link as the current page (`aria-current="page"`). Derive it from your route model. */
7
13
  readonly active?: boolean;
8
14
  readonly children?: JSX.Element;
9
15
  };
10
16
  type ClickEvent = Pick<MouseEvent, 'button' | 'metaKey' | 'altKey' | 'ctrlKey' | 'shiftKey' | 'defaultPrevented' | 'preventDefault'>;
11
17
  /** Resolve a typed destination to an href and a click handler that navigates in place. */
12
- export declare function linkTarget(router: AnyRouter, props: LinkOptions<AnyRouter, string, string> & Pick<Anchor, 'download'>): {
18
+ export declare function linkTarget(router: AnyRouter, props: SupportedLinkOptions<AnyRouter, string, string> & Pick<Anchor, 'download'>): {
13
19
  href: string | undefined;
14
20
  onClick: (event: ClickEvent) => void;
15
21
  };
package/dist/link.js CHANGED
@@ -1,79 +1,74 @@
1
1
  import * as _ew_dom from "effectweb/dom";
2
- const _ew_jsx0 = /*@__PURE__*/ _ew_dom.markup("a");
2
+ const _ew_jsx0 = /* @__PURE__ */ _ew_dom.markup("a");
3
3
  import { getUrlScheme, isDangerousProtocol } from "@tanstack/router-core";
4
4
  import { jsxComponent } from "effectweb/jsx";
5
5
  const options = [
6
- "to",
7
- "from",
8
- "params",
9
- "search",
10
- "hash",
11
- "state",
12
- "mask",
13
- "replace",
14
- "resetScroll",
15
- "hashScrollIntoView",
16
- "viewTransition",
17
- "ignoreBlocker",
18
- "reloadDocument",
19
- "href",
20
- "activeOptions",
21
- "preload",
22
- "preloadDelay",
23
- "preloadIntentProximity",
24
- "disabled",
25
- "active",
26
- "children"
6
+ "to",
7
+ "from",
8
+ "params",
9
+ "search",
10
+ "hash",
11
+ "state",
12
+ "mask",
13
+ "replace",
14
+ "resetScroll",
15
+ "hashScrollIntoView",
16
+ "viewTransition",
17
+ "ignoreBlocker",
18
+ "reloadDocument",
19
+ "href",
20
+ "activeOptions",
21
+ "preload",
22
+ "preloadDelay",
23
+ "preloadIntentProximity",
24
+ "disabled",
25
+ "active",
26
+ "children"
27
27
  ];
28
- /** Resolve a typed destination to an href and a click handler that navigates in place. */
29
- export function linkTarget(router, props) {
30
- const explicitHref = props.href;
31
- const blocked = explicitHref !== undefined && isDangerousProtocol(explicitHref, router.protocolAllowlist);
32
- const absoluteHref = explicitHref !== undefined && getUrlScheme(explicitHref) ? explicitHref : undefined;
33
- const location = absoluteHref || blocked ? undefined : router.buildLocation(props);
34
- const external = absoluteHref !== undefined || location?.external === true;
35
- const publicHref = absoluteHref ?? location?.publicHref;
36
- const history = router.history;
37
- const href = props.disabled || blocked ? undefined : props.reloadDocument || external ? publicHref : history.createHref(publicHref);
38
- const onClick = (event) => {
39
- const plain = event.button === 0 && !event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey && !event.defaultPrevented;
40
- if (props.disabled || blocked) {
41
- event.preventDefault();
42
- return;
43
- }
44
- // Let the browser handle new tabs, downloads and full reloads.
45
- const download = props.download !== undefined && props.download !== false;
46
- if (!plain || external || download || props.reloadDocument || props.target && props.target !== "_self")
47
- return;
48
- event.preventDefault();
49
- router.navigate(props).catch((error) => {
50
- console.error("Router navigation failed", error);
51
- });
52
- };
53
- return {
54
- href,
55
- onClick
56
- };
57
- }
58
- /**
59
- * Create a typed `<Link>` for one router. It renders a real `<a href>` (middle-click,
60
- * copy link and accessibility keep working) and navigates in place on plain clicks.
61
- */
62
- export function createLink(router) {
63
- function Link(props) {
64
- const target = linkTarget(router, props);
65
- const attributes = { ...props };
66
- for (const name of options)
67
- delete attributes[name];
68
- return _ew_jsx0({
69
- ...attributes,
70
- "href": target.href,
71
- "target": props.target,
72
- "aria-current": props.active ? "page" : undefined,
73
- "aria-disabled": props.disabled ? "true" : undefined,
74
- "onClick": target.onClick,
75
- children: props.children
76
- });
28
+ function linkTarget(router, props) {
29
+ const explicitHref = props.href;
30
+ const blocked = explicitHref !== void 0 && isDangerousProtocol(explicitHref, router.protocolAllowlist);
31
+ const absoluteHref = explicitHref !== void 0 && getUrlScheme(explicitHref) ? explicitHref : void 0;
32
+ const location = absoluteHref || blocked ? void 0 : router.buildLocation(props);
33
+ const external = absoluteHref !== void 0 || location?.external === true;
34
+ const publicHref = absoluteHref ?? location?.publicHref;
35
+ const history = router.history;
36
+ const href = props.disabled || blocked ? void 0 : props.reloadDocument || external ? publicHref : history.createHref(publicHref);
37
+ const onClick = (event) => {
38
+ const plain = event.button === 0 && !event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey && !event.defaultPrevented;
39
+ if (props.disabled || blocked) {
40
+ event.preventDefault();
41
+ return;
77
42
  }
78
- return Object.assign(Link, { [jsxComponent]: true });
43
+ const download = props.download !== void 0 && props.download !== false;
44
+ if (!plain || external || download || props.reloadDocument || props.target && props.target !== "_self") return;
45
+ event.preventDefault();
46
+ router.navigate(props).catch((error) => {
47
+ console.error("Router navigation failed", error);
48
+ });
49
+ };
50
+ return {
51
+ href,
52
+ onClick
53
+ };
54
+ }
55
+ function createLink(router) {
56
+ function Link(props) {
57
+ const target = linkTarget(router, props);
58
+ const attributes = { ...props };
59
+ for (const name of options) delete attributes[name];
60
+ return _ew_jsx0({
61
+ ...attributes,
62
+ "href": target.href,
63
+ "target": props.target,
64
+ "aria-current": props.active ? "page" : void 0,
65
+ "aria-disabled": props.disabled ? "true" : void 0,
66
+ "onClick": target.onClick
67
+ }, props.children);
68
+ }
69
+ return Object.assign(Link, { [jsxComponent]: true });
79
70
  }
71
+ export {
72
+ createLink,
73
+ linkTarget
74
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectweb/tanstack-router",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,10 +25,10 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@tanstack/history": "1.162.4",
28
- "@tanstack/router-core": "1.171.33"
28
+ "@tanstack/router-core": "1.171.34"
29
29
  },
30
30
  "peerDependencies": {
31
- "effect": "4.0.0-rc.112",
32
- "effectweb": "^0.8.0"
31
+ "effect": "^4.0.0",
32
+ "effectweb": "0.9.0"
33
33
  }
34
34
  }