From 8fa32de5f883d883f8944873a3335a814324db97 Mon Sep 17 00:00:00 2001 From: Sergey Sova Date: Sat, 4 Jul 2026 06:08:54 +0400 Subject: [PATCH 1/3] feat(solid): add @effector/router-solid draft package Add SolidJS bindings for @effector/router, mirroring the @effector/router-react API with Solid primitives (createMemo, Show, Dynamic, effector-solid useUnit). Exposes RouterProvider, Link/useLink, Outlet, createRouteView, createLazyRouteView, createRoutesView, withLayout, useRouter, useIsOpened and useOpenedViews. Reactive helpers return Solid accessors instead of plain values. Supports only the current stable major of SolidJS (1.x) via peerDependencies "solid-js": "^1.9.0". Wire-up: vite-plugin-solid build/test config (hot disabled for vitest), per-package tsconfig with jsxImportSource "solid-js" (root tsconfig excludes it), root devDependencies, ":solid" script and a changeset. --- .changeset/router-solid-draft.md | 9 + package.json | 5 + packages/solid/README.md | 89 ++++++++ packages/solid/lib/context.ts | 8 + packages/solid/lib/create-lazy-route-view.tsx | 52 +++++ packages/solid/lib/create-route-view.tsx | 42 ++++ packages/solid/lib/create-routes-view.tsx | 52 +++++ packages/solid/lib/index.ts | 17 ++ packages/solid/lib/link.tsx | 98 +++++++++ packages/solid/lib/outlet.tsx | 45 ++++ packages/solid/lib/router-provider.tsx | 20 ++ packages/solid/lib/types.ts | 45 ++++ packages/solid/lib/use-is-opened.ts | 16 ++ packages/solid/lib/use-link.ts | 36 ++++ packages/solid/lib/use-opened-views.ts | 33 +++ packages/solid/lib/use-router.ts | 24 +++ packages/solid/lib/with-layout.tsx | 47 +++++ packages/solid/package.json | 64 ++++++ packages/solid/tests/index.test.tsx | 76 +++++++ packages/solid/tsconfig.json | 10 + packages/solid/vite.config.ts | 67 ++++++ pnpm-lock.yaml | 198 ++++++++++++++++++ tsconfig.json | 5 +- 23 files changed, 1057 insertions(+), 1 deletion(-) create mode 100644 .changeset/router-solid-draft.md create mode 100644 packages/solid/README.md create mode 100644 packages/solid/lib/context.ts create mode 100644 packages/solid/lib/create-lazy-route-view.tsx create mode 100644 packages/solid/lib/create-route-view.tsx create mode 100644 packages/solid/lib/create-routes-view.tsx create mode 100644 packages/solid/lib/index.ts create mode 100644 packages/solid/lib/link.tsx create mode 100644 packages/solid/lib/outlet.tsx create mode 100644 packages/solid/lib/router-provider.tsx create mode 100644 packages/solid/lib/types.ts create mode 100644 packages/solid/lib/use-is-opened.ts create mode 100644 packages/solid/lib/use-link.ts create mode 100644 packages/solid/lib/use-opened-views.ts create mode 100644 packages/solid/lib/use-router.ts create mode 100644 packages/solid/lib/with-layout.tsx create mode 100644 packages/solid/package.json create mode 100644 packages/solid/tests/index.test.tsx create mode 100644 packages/solid/tsconfig.json create mode 100644 packages/solid/vite.config.ts diff --git a/.changeset/router-solid-draft.md b/.changeset/router-solid-draft.md new file mode 100644 index 0000000..18d35a2 --- /dev/null +++ b/.changeset/router-solid-draft.md @@ -0,0 +1,9 @@ +--- +'@effector/router-solid': minor +--- + +Add SolidJS bindings package `@effector/router-solid` (draft). Mirrors the +`@effector/router-react` API — `RouterProvider`, `createRouteView`, +`createLazyRouteView`, `createRoutesView`, `Link`/`useLink`, `Outlet`, +`withLayout`, `useRouter`, `useIsOpened`, `useOpenedViews` — implemented with +Solid primitives. Supports only the current major of SolidJS (1.x). diff --git a/package.json b/package.json index 596e605..fbace33 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ ":docs": "pnpm --filter @effector/router-docs", ":react": "pnpm --filter @effector/router-react", ":react-native": "pnpm --filter @effector/router-react-native", + ":solid": "pnpm --filter @effector/router-solid", ":paths": "pnpm --filter @effector/router-paths", ":core": "pnpm --filter @effector/router" }, @@ -36,10 +37,13 @@ "@vitejs/plugin-react": "^4.3.4", "effector": "^23.4.4", "effector-react": "^23.3.0", + "@solidjs/testing-library": "^0.8.10", + "effector-solid": "^0.23.0", "eslint": "10.0.2", "eslint-config-prettier": "10.1.8", "eslint-plugin-prettier": "5.5.5", "globals": "^17.4.0", + "solid-js": "^1.9.14", "happy-dom": "^20.8.3", "history": "5.3.0", "jiti": "^2.6.1", @@ -53,6 +57,7 @@ "vite": "7.3.2", "vite-plugin-babel": "1.5.1", "vite-plugin-dts": "4.5.4", + "vite-plugin-solid": "^2.11.12", "vitepress": "1.6.4", "vitest": "4.0.18", "zod": "^4.3.6" diff --git a/packages/solid/README.md b/packages/solid/README.md new file mode 100644 index 0000000..207e424 --- /dev/null +++ b/packages/solid/README.md @@ -0,0 +1,89 @@ +# ☄️ @effector/router-solid + +[![npm](https://img.shields.io/npm/v/@effector/router-solid.svg)](https://www.npmjs.com/package/@effector/router-solid) + +> ⚠️ **Draft.** API mirrors [`@effector/router-react`](https://www.npmjs.com/package/@effector/router-react) and may change before the first stable release. + +SolidJS bindings for [`@effector/router`](https://www.npmjs.com/package/@effector/router). Connect +routes to components, render the active route, and navigate with a `` — all driven by +Effector state. + +Only the current major of SolidJS (**1.x**) is supported. + +## Install + +```bash +npm install @effector/router-solid @effector/router effector effector-solid solid-js +``` + +## Quick start + +```tsx +import { createRoute, createRouter } from '@effector/router'; +import { + RouterProvider, + createRouteView, + createRoutesView, + Link, +} from '@effector/router-solid'; + +// 1. Routes + router (see @effector/router for history setup) +const home = createRoute({ path: '/' }); +const about = createRoute({ path: '/about' }); +const router = createRouter({ routes: [home, about] }); + +// 2. Bind each route to a component +const HomeScreen = createRouteView({ + route: home, + view: () => Go to About, +}); +const AboutScreen = createRouteView({ + route: about, + view: () => Back Home, +}); + +// 3. Render the active route +const Routes = createRoutesView({ routes: [HomeScreen, AboutScreen] }); + +export function App() { + return ( + + + + ); +} +``` + +Read route params in a view with effector-solid's `useUnit` (returns a Solid accessor): + +```tsx +import { useUnit } from 'effector-solid'; + +const UserScreen = createRouteView({ + route: userRoute, // createRoute({ path: '/user/:id' }) + view: () => { + const params = useUnit(userRoute.$params); + return
User {params().id}
; + }, +}); +``` + +## API + +- `RouterProvider` — provide the router to the tree. +- `createRouteView` / `createLazyRouteView` — bind a route to a component (with optional lazy loading). +- `createRoutesView` — render the currently opened route, with an `otherwise` fallback. +- `Link`, `useLink` — declarative and imperative navigation. +- `withLayout` — share a layout across routes. +- `Outlet`, `useRouter`, `useIsOpened`, `useOpenedViews` — composition helpers. + +Unlike the React binding, reactive helpers (`useLink`, `useIsOpened`, `useOpenedViews`) return Solid +**accessors** (`() => value`) instead of plain values. + +## Documentation + +Full guides and API reference: **[router.effector.dev/solid](https://router.effector.dev/solid)** + +## License + +[MIT](https://github.com/effector/router/blob/main/LICENSE) diff --git a/packages/solid/lib/context.ts b/packages/solid/lib/context.ts new file mode 100644 index 0000000..962b75d --- /dev/null +++ b/packages/solid/lib/context.ts @@ -0,0 +1,8 @@ +import type { Router } from '@effector/router'; +import { createContext } from 'solid-js'; +import type { RouteView } from './types'; + +export const RouterProviderContext = createContext(null); +export const OutletContext = createContext<{ children: RouteView[] }>({ + children: [], +}); diff --git a/packages/solid/lib/create-lazy-route-view.tsx b/packages/solid/lib/create-lazy-route-view.tsx new file mode 100644 index 0000000..b176c90 --- /dev/null +++ b/packages/solid/lib/create-lazy-route-view.tsx @@ -0,0 +1,52 @@ +import { lazy, Suspense } from 'solid-js'; +import { is } from '@effector/router'; +import type { InternalRoute } from '@effector/router'; +import type { CreateLazyRouteViewProps, RouteView } from './types'; + +/** + * @description Creates Lazy route view with async bundle load + * @link https://router.effector.dev/solid/create-lazy-route-view.html + * @param props Lazy route view props + * @returns RouteView + * @example ```ts + * // profile.tsx + * export default function () { + * return <>...; + * } + * + * // index.ts + * import { createLazyRouteView } from '@effector/router-solid'; + * import { routes } from '@shared/routing'; + * import { MainLayout } from '@layouts'; + * + * export const ProfileScreen = createLazyRouteView({ + * route: routes.profile, + * view: () => import('./profile'), + * fallback: () => ':(', + * layout: MainLayout, + * }); + * ``` + */ +export function createLazyRouteView( + props: CreateLazyRouteViewProps, +): RouteView { + if (!is.router(props.route)) { + (props.route as InternalRoute).internal.setAsyncImport(props.view); + } + + const View = lazy(props.view); + const { layout: Layout, fallback: Fallback } = props; + + const inner = () => ( + : null}> + + + ); + + const view = Layout ? () => {inner()} : inner; + + return { + route: props.route, + view, + }; +} diff --git a/packages/solid/lib/create-route-view.tsx b/packages/solid/lib/create-route-view.tsx new file mode 100644 index 0000000..056acf9 --- /dev/null +++ b/packages/solid/lib/create-route-view.tsx @@ -0,0 +1,42 @@ +import type { CreateRouteViewProps, RouteView } from './types'; + +/** + * @description Creates Route view without async bundle load + * @link https://router.effector.dev/solid/create-route-view.html + * @param props Route view props + * @returns RouteView + * @example ```ts + * import { createRouteView } from '@effector/router-solid'; + * import { routes } from '@shared/routing'; + * import { MainLayout } from '@layouts'; + * + * function Profile() { + * return <>...; + * } + * + * export const ProfileScreen = createRouteView({ + * route: routes.profile, + * view: Profile, + * layout: MainLayout, + * }); + * ``` + */ +export function createRouteView( + props: CreateRouteViewProps, +): RouteView { + const { layout: Layout, view: View, children } = props; + + const view = Layout + ? () => ( + + + + ) + : () => ; + + return { + route: props.route, + view, + children, + }; +} diff --git a/packages/solid/lib/create-routes-view.tsx b/packages/solid/lib/create-routes-view.tsx new file mode 100644 index 0000000..d6394c2 --- /dev/null +++ b/packages/solid/lib/create-routes-view.tsx @@ -0,0 +1,52 @@ +import { Show, type Component } from 'solid-js'; +import { Dynamic } from 'solid-js/web'; +import { OutletContext } from './context'; +import { useOpenedViews } from './use-opened-views'; +import type { RouteView } from './types'; + +interface CreateRoutesViewProps { + routes: RouteView[]; + otherwise?: Component; +} + +/** + * @description Create routes view which renders current opened route. `Don't forget add `! + * @param props Routes view config + * @link https://router.effector.dev/solid/create-routes-view.html + * @returns RoutesView + * @example ```tsx + * import { createRoutesView } from '@effector/router-solid'; + * import { router } from './router'; + * // feed screen & profile screen must be created with createRouteView! + * import { FeedScreen, ProfileScreen } from './screens'; + * + * const RoutesView = createRoutesView({ routes: [FeedScreen, ProfileScreen] }); + * + * // then you can use it like a Solid component: + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ +export function createRoutesView(props: CreateRoutesViewProps) { + const { routes, otherwise: NotFound } = props; + + return () => { + const openedViews = useOpenedViews(routes); + const openedView = () => openedViews().at(-1); + + return ( + : null} keyed> + {(view) => ( + + + + )} + + ); + }; +} diff --git a/packages/solid/lib/index.ts b/packages/solid/lib/index.ts new file mode 100644 index 0000000..7500cc0 --- /dev/null +++ b/packages/solid/lib/index.ts @@ -0,0 +1,17 @@ +export { RouterProvider } from './router-provider'; +export { Link } from './link'; +export { createRouteView } from './create-route-view'; +export { createRoutesView } from './create-routes-view'; +export { createLazyRouteView } from './create-lazy-route-view'; +export { useRouter, useRouterContext } from './use-router'; +export { withLayout } from './with-layout'; +export type { + LinkProps, + CreateLazyRouteViewProps, + CreateRouteViewProps, + RouteView, +} from './types'; +export { Outlet } from './outlet'; +export { useOpenedViews } from './use-opened-views'; +export { useIsOpened } from './use-is-opened'; +export { useLink } from './use-link'; diff --git a/packages/solid/lib/link.tsx b/packages/solid/lib/link.tsx new file mode 100644 index 0000000..2323c2d --- /dev/null +++ b/packages/solid/lib/link.tsx @@ -0,0 +1,98 @@ +import type { Route, RouteOpenedPayload } from '@effector/router'; +import { splitProps, type JSX } from 'solid-js'; +import type { LinkProps } from './types'; +import { useLink } from './use-link'; + +/** + * @description Navigates user to provided route on click + * @link https://router.effector.dev/solid/link.html + * @example ```tsx + * import { Link } from '@effector/router-solid'; + * import { routes } from '@shared/routing'; + * + * function Profile(props) { + * return ( + * <> + * Settings + * + * + * {(post) => ( + * + * Edit post + * + * )} + * + * + * ); + * } + * ``` + */ +export function Link( + props: LinkProps, +) { + const [local, anchorProps] = splitProps(props, [ + 'to', + 'params', + 'onClick', + 'replace', + 'query', + 'children', + ]); + + const { path, onOpen } = useLink( + local.to as Route, + () => local.params as Params, + ); + + const handleClick: JSX.EventHandler = ( + event, + ) => { + callClickHandler(local.onClick, event); + + // allow user to prevent navigation + if (event.defaultPrevented) { + return; + } + + // let browser handle "_blank" target and etc + if (anchorProps.target && anchorProps.target !== '_self') { + return; + } + + // skip modified events (like cmd + click to open the link in new tab) + if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) { + return; + } + + event.preventDefault(); + + onOpen({ + params: local.params || {}, + replace: local.replace, + query: local.query, + } as RouteOpenedPayload); + }; + + return ( + + {local.children} + + ); +} + +// Solid event handlers can be a plain function or a bound `[handler, data]` +// tuple. Support both so consumers can pass either form. +function callClickHandler( + handler: JSX.EventHandlerUnion | undefined, + event: MouseEvent & { currentTarget: HTMLAnchorElement; target: Element }, +) { + if (!handler) { + return; + } + + if (typeof handler === 'function') { + handler(event); + } else { + handler[0](handler[1], event); + } +} diff --git a/packages/solid/lib/outlet.tsx b/packages/solid/lib/outlet.tsx new file mode 100644 index 0000000..83703a8 --- /dev/null +++ b/packages/solid/lib/outlet.tsx @@ -0,0 +1,45 @@ +import { Show, useContext } from 'solid-js'; +import { Dynamic } from 'solid-js/web'; +import { OutletContext } from './context'; +import { useOpenedViews } from './use-opened-views'; + +/** + * @description Outlet component for nested routes + * @link https://router.effector.dev/solid/outlet.html + * @example ```tsx + * export const RoutesView = createRoutesView({ + * routes: [ + * createRouteView({ + * route: routes.profile, + * view: ProfileScreen, + * children: [ + * createRouteView({ route: routes.settings, view: SettingsScreen }), + * ], + * }), + * ], + * }); + * + * // profile.tsx + * export const ProfileScreen = () => { + * // will render settings screen when profile route is opened + * // and settings route is active + * return ( + * <> + *
Profile
+ * + * + * ); + * }; + * ``` + */ +export function Outlet() { + const { children } = useContext(OutletContext); + const openedViews = useOpenedViews(children); + const openedView = () => openedViews().at(-1); + + return ( + + {(view) => } + + ); +} diff --git a/packages/solid/lib/router-provider.tsx b/packages/solid/lib/router-provider.tsx new file mode 100644 index 0000000..b670661 --- /dev/null +++ b/packages/solid/lib/router-provider.tsx @@ -0,0 +1,20 @@ +import type { JSX } from 'solid-js'; +import type { Router } from '@effector/router'; +import { RouterProviderContext } from './context'; + +interface RouterProviderProps { + children?: JSX.Element; + router: Router; +} + +/** + * @description Provides router in Solid tree + * @param props Router provider config + */ +export function RouterProvider(props: RouterProviderProps) { + return ( + + {props.children} + + ); +} diff --git a/packages/solid/lib/types.ts b/packages/solid/lib/types.ts new file mode 100644 index 0000000..d7035fc --- /dev/null +++ b/packages/solid/lib/types.ts @@ -0,0 +1,45 @@ +import type { Route, OpenPayloadBase, Router } from '@effector/router'; +import type { Component, JSX } from 'solid-js'; + +type LayoutComponent = Component<{ children: JSX.Element }>; +type RouteViewWithLayout = RouteView & { layout?: LayoutComponent }; + +interface CreateBaseRouteViewProps { + route: Route | Router; + layout?: LayoutComponent; + children?: RouteViewWithLayout[]; +} + +export interface CreateRouteViewProps< + T extends object | void = void, +> extends CreateBaseRouteViewProps { + view: Component; +} + +export interface CreateLazyRouteViewProps< + T extends object | void = void, +> extends CreateBaseRouteViewProps { + view: () => Promise<{ default: Component }>; + fallback?: Component; +} + +export interface RouteView { + route: Route | Router; + view: Component; + children?: RouteView[]; +} + +type AnchorProps = Omit, 'href'>; + +type BaseLinkProps = { + to: Route; + children?: JSX.Element; +} & AnchorProps & + OpenPayloadBase; + +export type LinkProps = Params extends + | Record + | void + | undefined + ? BaseLinkProps & { params?: Params } + : BaseLinkProps & { params: Params }; diff --git a/packages/solid/lib/use-is-opened.ts b/packages/solid/lib/use-is-opened.ts new file mode 100644 index 0000000..4707c37 --- /dev/null +++ b/packages/solid/lib/use-is-opened.ts @@ -0,0 +1,16 @@ +import { is, type Route, type Router } from '@effector/router'; +import { useUnit } from 'effector-solid'; +import { createMemo, type Accessor } from 'solid-js'; + +/** + * @description Reactive accessor telling whether a route (or any route of a + * router) is currently opened. + */ +export function useIsOpened(route: Route | Router): Accessor { + if (is.router(route)) { + const activeRoutes = useUnit(route.$activeRoutes); + return createMemo(() => activeRoutes().length > 0); + } + + return useUnit(route.$isOpened); +} diff --git a/packages/solid/lib/use-link.ts b/packages/solid/lib/use-link.ts new file mode 100644 index 0000000..b8cd67d --- /dev/null +++ b/packages/solid/lib/use-link.ts @@ -0,0 +1,36 @@ +import type { Route, InternalRoute } from '@effector/router'; +import { createMemo, type Accessor } from 'solid-js'; +import { useRouterContext } from './use-router'; +import { useUnit } from 'effector-solid'; + +/** + * @description Imperative navigation helper. Resolves a route to a reactive + * `path` accessor and an `onOpen` event you can call to navigate. + * @param to Target route (must be registered in the router) + * @param params Accessor to the route params (reactive) + */ +export function useLink( + to: Route, + params: Accessor = (() => undefined) as Accessor, +) { + const { knownRoutes } = useRouterContext(); + const target = knownRoutes.find( + ({ route }) => route === (to as unknown as InternalRoute), + ); + + if (!target) { + console.error(`[useLink route log]`, to); + throw new Error( + `[useLink] Route not found. Maybe it is not passed into createRouter?`, + ); + } + + const { onOpen } = useUnit(to); + + const path = createMemo(() => target.build(params() ?? undefined)); + + return { + path, + onOpen, + }; +} diff --git a/packages/solid/lib/use-opened-views.ts b/packages/solid/lib/use-opened-views.ts new file mode 100644 index 0000000..fd5c630 --- /dev/null +++ b/packages/solid/lib/use-opened-views.ts @@ -0,0 +1,33 @@ +import { createMemo, type Accessor } from 'solid-js'; +import type { RouteView } from './types'; +import type { InternalRoute } from '@effector/router'; +import { is } from '@effector/router'; +import { useUnit } from 'effector-solid'; + +/** + * @description Reactive accessor with the currently opened views out of the + * provided list. Parent views are filtered out when a child view is opened, so + * only the deepest matching branch remains. + */ +export function useOpenedViews(routes: RouteView[]): Accessor { + const visibilities = routes.map>((view) => { + if (is.router(view.route)) { + const activeRoutes = useUnit(view.route.$activeRoutes); + return () => activeRoutes().length > 0; + } + + return useUnit(view.route.$isOpened); + }); + + return createMemo(() => { + const filtered = routes.filter((_, index) => visibilities[index]()); + + return filtered.reduce( + (acc, view) => + acc.filter( + (r) => r.route !== (view.route as InternalRoute).parent, + ), + filtered, + ); + }); +} diff --git a/packages/solid/lib/use-router.ts b/packages/solid/lib/use-router.ts new file mode 100644 index 0000000..490c028 --- /dev/null +++ b/packages/solid/lib/use-router.ts @@ -0,0 +1,24 @@ +import { useContext } from 'solid-js'; +import { RouterProviderContext } from './context'; +import { useUnit } from 'effector-solid'; + +export function useRouterContext() { + const context = useContext(RouterProviderContext); + + if (!context) { + throw new Error( + '[useRouter] Router not found. Add RouterProvider in app root', + ); + } + + return context; +} + +/** + * @description Use router from provider + * @returns Router unit shape (Solid accessors) + * @link https://router.effector.dev/solid/use-router.html + */ +export function useRouter() { + return useUnit(useRouterContext()); +} diff --git a/packages/solid/lib/with-layout.tsx b/packages/solid/lib/with-layout.tsx new file mode 100644 index 0000000..2dabc7f --- /dev/null +++ b/packages/solid/lib/with-layout.tsx @@ -0,0 +1,47 @@ +import type { Component, JSX } from 'solid-js'; +import type { RouteView } from './types'; + +/** + * @description Group routes by layout, so you don't need to pass `layout` property manually in all routes. Works for `createRouteView` and `createLazyRouteView`. + * @link https://router.effector.dev/solid/with-layout.html + * @example ```tsx + * import { + * createRoutesView, + * createRouteView, + * withLayout, + * } from '@effector/router-solid'; + * + * import { SignInScreen } from './sign-in'; + * import { SignUpScreen } from './sign-up'; + * import { ProfileScreen } from './profile'; + * + * import { routes } from '@shared/routing'; + * + * import { AuthLayout } from '@layouts/auth'; + * + * export const RoutesView = createRoutesView({ + * routes: [ + * ...withLayout(AuthLayout, [ + * createRouteView({ route: routes.signIn, view: SignInScreen }), + * createRouteView({ route: routes.signUp, view: SignUpScreen }), + * ]), + * createRouteView({ route: routes.profile, view: ProfileScreen }), + * ], + * }); + * ``` + */ +export function withLayout( + layout: Component<{ children: JSX.Element }>, + views: RouteView[], +): RouteView[] { + const Layout = layout; + + return views.map(({ route, view: View }) => ({ + route, + view: () => ( + + + + ), + })); +} diff --git a/packages/solid/package.json b/packages/solid/package.json new file mode 100644 index 0000000..74bc401 --- /dev/null +++ b/packages/solid/package.json @@ -0,0 +1,64 @@ +{ + "name": "@effector/router-solid", + "version": "0.1.0", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "private": false, + "description": "SolidJS bindings for effector router", + "keywords": [ + "effector", + "argon", + "atomic", + "router", + "effector/router", + "solid", + "solid-js" + ], + "contributors": [ + "Sergey Sova ", + "Anton Kosykh", + "Zero Bias ", + "movpushmov" + ], + "homepage": "https://router.effector.dev/solid", + "license": "MIT", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "directories": { + "lib": "lib" + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/effector/router.git", + "directory": "packages/solid" + }, + "scripts": { + "build": "vite build", + "test": "vitest run" + }, + "bugs": { + "url": "https://github.com/effector/router/issues" + }, + "dependencies": { + "@effector/router": "workspace:^" + }, + "peerDependencies": { + "effector": ">=23", + "effector-solid": ">=0.23", + "solid-js": "^1.9.0" + } +} diff --git a/packages/solid/tests/index.test.tsx b/packages/solid/tests/index.test.tsx new file mode 100644 index 0000000..10edcff --- /dev/null +++ b/packages/solid/tests/index.test.tsx @@ -0,0 +1,76 @@ +import { allSettled, fork } from 'effector'; +import { Provider } from 'effector-solid'; +import { describe, expect, test } from 'vitest'; +import { render } from '@solidjs/testing-library'; +import { createRoute, createRouter, historyAdapter } from '@effector/router'; +import { createMemoryHistory } from 'history'; + +import { createRoutesView, Link, RouterProvider } from '../lib'; + +describe('solid bindings', () => { + test('component changes when path changes', async () => { + const route1 = createRoute({ path: '/app' }); + const route2 = createRoute({ path: '/faq' }); + + const scope = fork(); + const router = createRouter({ routes: [route1, route2] }); + + const history = createMemoryHistory(); + + await allSettled(router.setHistory, { + scope, + params: historyAdapter(history), + }); + + const RoutesView = createRoutesView({ + routes: [ + { route: route1, view: () =>

route1

}, + { route: route2, view: () =>

route2

}, + ], + otherwise: () =>

not found

, + }); + + const { container } = render(() => ( + + + + + + )); + + await allSettled(route1.open, { scope, params: undefined }); + expect(container.querySelector('#message')?.textContent).toBe('route1'); + + await allSettled(route2.open, { scope, params: undefined }); + expect(container.querySelector('#message')?.textContent).toBe('route2'); + + history.push('/not-found'); + await allSettled(scope); + expect(container.querySelector('#message')?.textContent).toBe('not found'); + }); + + test('link renders resolved href', async () => { + const route1 = createRoute({ path: '/app' }); + const route2 = createRoute({ path: '/faq/:id' }); + + const scope = fork(); + const router = createRouter({ routes: [route1, route2] }); + + await allSettled(router.setHistory, { + scope, + params: historyAdapter(createMemoryHistory()), + }); + + const { container } = render(() => ( + + + + open + + + + )); + + expect(container.querySelector('a')?.getAttribute('href')).toBe('/faq/42'); + }); +}); diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json new file mode 100644 index 0000000..a46fc79 --- /dev/null +++ b/packages/solid/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite/client", "vitest/globals"] + }, + "include": ["lib", "tests", "vite.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/solid/vite.config.ts b/packages/solid/vite.config.ts new file mode 100644 index 0000000..b6b8914 --- /dev/null +++ b/packages/solid/vite.config.ts @@ -0,0 +1,67 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; +import dts from 'vite-plugin-dts'; +import solid from 'vite-plugin-solid'; + +export default defineConfig({ + mode: 'production', + build: { + lib: { + entry: resolve(__dirname, 'lib/index.ts'), + fileName: 'index', + formats: ['es', 'cjs'], + }, + rollupOptions: { + external: [ + 'effector', + 'effector-solid', + '@effector/router', + 'solid-js', + 'solid-js/web', + 'solid-js/store', + ], + output: { + globals: { + 'solid-js': 'solid-js', + 'solid-js/web': 'solid-js/web', + 'solid-js/store': 'solid-js/store', + effector: 'effector', + 'effector-solid': 'effector-solid', + '@effector/router': '@effector/router', + }, + }, + }, + }, + plugins: [ + // `hot` (solid-refresh) is only useful behind a dev server; this package is + // consumed as a library and built/tested only, and the refresh runtime + // breaks under vitest, so keep it disabled. + solid({ hot: false }), + dts({ + outDir: resolve(__dirname, 'dist'), + entryRoot: resolve(__dirname, 'lib'), + exclude: [ + resolve(__dirname, 'tests'), + resolve(__dirname, '../core'), + resolve(__dirname, '../paths'), + resolve(__dirname, '../react'), + resolve(__dirname, '../react-native'), + ], + staticImport: true, + insertTypesEntry: true, + rollupTypes: true, + }), + ], + resolve: { + conditions: ['development', 'browser'], + }, + test: { + globals: true, + environment: 'happy-dom', + server: { + deps: { + inline: [/solid-js/, /@solidjs\/testing-library/, /effector-solid/], + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a21edba..4925e1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@react-navigation/stack': specifier: 7.8.4 version: 7.8.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-gesture-handler@2.30.0(react-native@0.84.1(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + '@solidjs/testing-library': + specifier: ^0.8.10 + version: 0.8.10(solid-js@1.9.14) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -65,6 +68,9 @@ importers: effector-react: specifier: ^23.3.0 version: 23.3.0(effector@23.4.4)(react@19.2.4) + effector-solid: + specifier: ^0.23.0 + version: 0.23.0(effector@23.4.4)(solid-js@1.9.14) eslint: specifier: 10.0.2 version: 10.0.2(jiti@2.6.1) @@ -104,6 +110,9 @@ importers: react-native: specifier: 0.84.1 version: 0.84.1(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4) + solid-js: + specifier: ^1.9.14 + version: 1.9.14 typescript-eslint: specifier: ^8.56.1 version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.8.2) @@ -116,6 +125,9 @@ importers: vite-plugin-dts: specifier: 4.5.4 version: 4.5.4(@types/node@25.3.3)(rollup@4.60.2)(typescript@5.8.2)(vite@7.3.2(@types/node@25.3.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + vite-plugin-solid: + specifier: ^2.11.12 + version: 2.11.12(solid-js@1.9.14)(vite@7.3.2(@types/node@25.3.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) vitepress: specifier: 1.6.4 version: 1.6.4(@algolia/client-search@5.49.1)(@types/node@25.3.3)(@types/react@19.2.14)(postcss@8.5.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(terser@5.46.0)(typescript@5.8.2) @@ -216,6 +228,21 @@ importers: specifier: '>=0.70' version: 0.84.1(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4) + packages/solid: + dependencies: + '@effector/router': + specifier: workspace:^ + version: link:../core + effector: + specifier: '>=23' + version: 23.4.4 + effector-solid: + specifier: '>=0.23' + version: 0.23.0(effector@23.4.4)(solid-js@1.9.14) + solid-js: + specifier: ^1.9.0 + version: 1.9.14 + packages: '@algolia/abtesting@1.15.1': @@ -346,6 +373,10 @@ packages: resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} @@ -1949,6 +1980,16 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@solidjs/testing-library@0.8.10': + resolution: {integrity: sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/router': '>=0.9.0' + solid-js: '>=1.0.0' + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2463,6 +2504,11 @@ packages: resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + babel-plugin-polyfill-corejs2@0.4.15: resolution: {integrity: sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==} peerDependencies: @@ -2492,6 +2538,15 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2762,6 +2817,13 @@ packages: effector: ^23.0.0 react: '>=16.8.0 <20.0.0' + effector-solid@0.23.0: + resolution: {integrity: sha512-2y+tdrkAe4Av21bEtXjEZ+rSSKOGxDDJNbYIFW/y3Q9io5zsJSVtaF19/Smr7qYLD6SzhhKZEUUdWmtpfD9R9Q==} + engines: {node: '>=11.0.0'} + peerDependencies: + effector: ^23.0.0 + solid-js: '>= 1.3.0' + effector@23.4.4: resolution: {integrity: sha512-QkZboRN28K/iwxigDhlJcI3ux3aNbt8kYGGH/GkqWG0OlGeyuBhb7PdM89Iu+ogV8Lmz16xIlwnXR2UNWI6psg==} engines: {node: '>=11.0.0'} @@ -2787,6 +2849,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -3120,6 +3186,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -3206,6 +3275,10 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -3402,6 +3475,10 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3669,6 +3746,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3991,6 +4071,16 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -4038,6 +4128,14 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + solid-js@1.9.14: + resolution: {integrity: sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4363,6 +4461,16 @@ packages: vite: optional: true + vite-plugin-solid@2.11.12: + resolution: {integrity: sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4434,6 +4542,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitepress@1.6.4: resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} hasBin: true @@ -4805,6 +4921,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.0 + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -6499,6 +6619,11 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@solidjs/testing-library@0.8.10(solid-js@1.9.14)': + dependencies: + '@testing-library/dom': 10.4.1 + solid-js: 1.9.14 + '@standard-schema/spec@1.1.0': {} '@testing-library/dom@10.4.1': @@ -7157,6 +7282,15 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + html-entities: 2.3.3 + parse5: 7.3.0 + babel-plugin-polyfill-corejs2@0.4.15(@babel/core@7.29.0): dependencies: '@babel/compat-data': 7.29.0 @@ -7210,6 +7344,13 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + babel-preset-solid@1.9.12(@babel/core@7.29.0)(solid-js@1.9.14): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.0) + optionalDependencies: + solid-js: 1.9.14 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -7439,6 +7580,11 @@ snapshots: react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) + effector-solid@0.23.0(effector@23.4.4)(solid-js@1.9.14): + dependencies: + effector: 23.4.4 + solid-js: 1.9.14 + effector@23.4.4: {} electron-to-chromium@1.5.307: {} @@ -7456,6 +7602,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@6.0.1: {} + entities@7.0.1: {} error-stack-parser@2.1.4: @@ -7844,6 +7992,8 @@ snapshots: hookable@5.5.3: {} + html-entities@2.3.3: {} + html-void-elements@3.0.0: {} http-errors@2.0.1: @@ -7917,6 +8067,8 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-what@4.1.16: {} + is-what@5.5.0: {} is-windows@1.0.2: {} @@ -8153,6 +8305,10 @@ snapshots: memoize-one@5.2.1: {} + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -8532,6 +8688,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -8879,6 +9039,12 @@ snapshots: serialize-error@2.1.0: {} + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -8929,6 +9095,21 @@ snapshots: slash@3.0.0: {} + solid-js@1.9.14: + dependencies: + csstype: 3.2.3 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + solid-refresh@0.6.3(solid-js@1.9.14): + dependencies: + '@babel/generator': 7.29.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/types': 7.29.0 + solid-js: 1.9.14 + transitivePeerDependencies: + - supports-color + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -9238,6 +9419,19 @@ snapshots: - rollup - supports-color + vite-plugin-solid@2.11.12(solid-js@1.9.14)(vite@7.3.2(@types/node@25.3.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)): + dependencies: + '@babel/core': 7.29.0 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.12(@babel/core@7.29.0)(solid-js@1.9.14) + merge-anything: 5.1.7 + solid-js: 1.9.14 + solid-refresh: 0.6.3(solid-js@1.9.14) + vite: 7.3.2(@types/node@25.3.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) + vitefu: 1.1.3(vite@7.3.2(@types/node@25.3.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + transitivePeerDependencies: + - supports-color + vite@5.4.21(@types/node@25.3.3)(terser@5.46.0): dependencies: esbuild: 0.21.5 @@ -9263,6 +9457,10 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 + vitefu@1.1.3(vite@7.3.2(@types/node@25.3.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)): + optionalDependencies: + vite: 7.3.2(@types/node@25.3.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) + vitepress@1.6.4(@algolia/client-search@5.49.1)(@types/node@25.3.3)(@types/react@19.2.14)(postcss@8.5.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(terser@5.46.0)(typescript@5.8.2): dependencies: '@docsearch/css': 3.8.2 diff --git a/tsconfig.json b/tsconfig.json index 1549df7..bb35b21 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -105,5 +105,8 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + // Solid uses a different JSX runtime (jsxImportSource: "solid-js"); it is + // typechecked via packages/solid/tsconfig.json instead of the root react-jsx. + "exclude": ["packages/solid"] } From 8b99dafc176a0c9dc85d8e7d2267c56d6789e972 Mon Sep 17 00:00:00 2001 From: Sergey Sova Date: Sat, 4 Jul 2026 06:13:56 +0400 Subject: [PATCH 2/3] test(solid): add example app router test with 404 handling Add a small two-page example (home + post view) exercising the router: - guards /post/:id with chainRoute + an existence-check effect, so the guarded (virtual) route only opens when the post exists; - renders the guarded route in createRoutesView, so a missing post id and a malformed/unknown url both fall through to the `otherwise` 404 fallback; - covers home, existing post, missing post -> 404, bad url -> 404, and recovery from 404 back to a real page. --- packages/solid/tests/example-app.test.tsx | 178 ++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 packages/solid/tests/example-app.test.tsx diff --git a/packages/solid/tests/example-app.test.tsx b/packages/solid/tests/example-app.test.tsx new file mode 100644 index 0000000..d7be3ff --- /dev/null +++ b/packages/solid/tests/example-app.test.tsx @@ -0,0 +1,178 @@ +import { allSettled, createEffect, fork } from 'effector'; +import { Provider, useUnit } from 'effector-solid'; +import { describe, expect, test } from 'vitest'; +import { render, screen } from '@solidjs/testing-library'; +import { + chainRoute, + createRoute, + createRouter, + historyAdapter, + type RouteOpenedPayload, +} from '@effector/router'; +import { createMemoryHistory } from 'history'; + +import { + createRouteView, + createRoutesView, + Link, + RouterProvider, +} from '../lib'; + +// --- "database" ----------------------------------------------------------- +const posts: Record = { + '1': 'Hello world', + '2': 'Effector rules', +}; + +// --- routes --------------------------------------------------------------- +const homeRoute = createRoute({ path: '/' }); +const postRoute = createRoute({ path: '/post/:id' }); + +// Guard the post route: only open it when the requested post exists. +// When the effect rejects, `cancelOn` keeps the virtual route closed, so the +// routes view falls through to the `otherwise` (404) fallback. +const loadPostFx = createEffect( + ({ params }: RouteOpenedPayload<{ id: string }>) => { + const title = posts[params.id]; + if (!title) { + throw new Error(`Post "${params.id}" not found`); + } + return title; + }, +); + +const postVisible = chainRoute({ + route: postRoute, + beforeOpen: loadPostFx, + openOn: loadPostFx.done, + cancelOn: loadPostFx.fail, +}); + +// --- pages ---------------------------------------------------------------- +function HomePage() { + return ( +
+

Home

+ + Read first post + +
+ ); +} + +function PostPage() { + const params = useUnit(postVisible.$params); + return ( +
+

Post

+

{posts[params().id]}

+
+ ); +} + +function NotFoundPage() { + return ( +
+

404 — Not Found

+
+ ); +} + +const RoutesView = createRoutesView({ + routes: [ + createRouteView({ route: homeRoute, view: HomePage }), + createRouteView({ route: postVisible, view: PostPage }), + ], + otherwise: NotFoundPage, +}); + +function setup(initialPath: string) { + const scope = fork(); + const router = createRouter({ routes: [homeRoute, postRoute] }); + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + render(() => ( + + + + + + )); + + return { scope, router, history }; +} + +describe('example app routing', () => { + test('renders the home page on "/"', async () => { + const { scope, router, history } = setup('/'); + + await allSettled(router.setHistory, { + scope, + params: historyAdapter(history), + }); + + expect(screen.getByTestId('page').textContent).toContain('Home'); + }); + + test('renders the post page for an existing post', async () => { + const { scope, router, history } = setup('/'); + + await allSettled(router.setHistory, { + scope, + params: historyAdapter(history), + }); + + history.push('/post/2'); + await allSettled(scope); + + expect(screen.getByTestId('page').textContent).toContain('Post'); + expect(screen.getByTestId('post-title').textContent).toBe('Effector rules'); + }); + + test('shows 404 when the post does not exist', async () => { + const { scope, router, history } = setup('/'); + + await allSettled(router.setHistory, { + scope, + params: historyAdapter(history), + }); + + // URL matches /post/:id, but there is no post with this id + history.push('/post/999'); + await allSettled(scope); + + expect(screen.getByTestId('page').textContent).toContain('404'); + }); + + test('shows 404 for an unknown / malformed url', async () => { + const { scope, router, history } = setup('/'); + + await allSettled(router.setHistory, { + scope, + params: historyAdapter(history), + }); + + // No route matches this path at all + history.push('/this/does/not/exist'); + await allSettled(scope); + + expect(screen.getByTestId('page').textContent).toContain('404'); + }); + + test('recovers from 404 back to a real page', async () => { + const { scope, router, history } = setup('/'); + + await allSettled(router.setHistory, { + scope, + params: historyAdapter(history), + }); + + history.push('/nope'); + await allSettled(scope); + expect(screen.getByTestId('page').textContent).toContain('404'); + + history.push('/post/1'); + await allSettled(scope); + expect(screen.getByTestId('post-title').textContent).toBe('Hello world'); + }); +}); From af2789d0ec7b2833d38c1e245a189698fddf21d2 Mon Sep 17 00:00:00 2001 From: Sergey Sova Date: Sat, 4 Jul 2026 17:21:12 +0400 Subject: [PATCH 3/3] docs(solid): list @effector/router-solid in README and docs site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Present the new SolidJS binding alongside the other packages: - root README: packages table, workspace shortcuts, and project structure tree; - docs sidebar: add a "Solid β" section; - docs: add a Solid overview page mirroring the React one. --- README.md | 6 +- docs/.vitepress/config.mts | 5 ++ docs/solid/index.md | 152 +++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 docs/solid/index.md diff --git a/README.md b/README.md index 288e88c..65496ce 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A type-safe, framework-agnostic router built on top of [Effector](https://effect | [`@effector/router-paths`](packages/paths) | Path parsing and matching utilities. | | [`@effector/router-react`](packages/react) | React bindings for the router. | | [`@effector/router-react-native`](packages/react-native) | React Native bindings for the router. | +| [`@effector/router-solid`](packages/solid) | SolidJS bindings for the router (draft). | ## Contributing @@ -56,7 +57,7 @@ Run from the repository root: | `pnpm changeset` | Create a changeset describing your change. | To work on a single package, use the workspace shortcuts, e.g. `pnpm :core build` or `pnpm :react test` -(shortcuts: `:core`, `:paths`, `:react`, `:react-native`, `:docs`). +(shortcuts: `:core`, `:paths`, `:react`, `:react-native`, `:solid`, `:docs`). ### Running tests @@ -91,7 +92,8 @@ router/ │ ├── core/ # @effector/router — core router │ ├── paths/ # @effector/router-paths — path utilities │ ├── react/ # @effector/router-react — React bindings -│ └── react-native/ # @effector/router-react-native — React Native bindings +│ ├── react-native/ # @effector/router-react-native — React Native bindings +│ └── solid/ # @effector/router-solid — SolidJS bindings (draft) ├── docs/ # VitePress documentation site ├── .changeset/ # pending changesets + config └── .github/ diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4846817..e13f430 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -104,6 +104,11 @@ export default defineConfig({ }, ], }, + { + text: 'Solid β', + collapsed: false, + items: [{ text: 'Overview', link: '/solid/' }], + }, ], footer: { diff --git a/docs/solid/index.md b/docs/solid/index.md new file mode 100644 index 0000000..f9306ad --- /dev/null +++ b/docs/solid/index.md @@ -0,0 +1,152 @@ +# Solid + +SolidJS bindings for effector/router, providing components and reactive helpers for seamless integration. + +::: warning Draft +`@effector/router-solid` is an early draft. Its API mirrors [`@effector/router-react`](/react/) +and may change before the first stable release. Only the current stable major of SolidJS (**1.x**) +is supported. +::: + +## Overview + +`@effector/router-solid` provides Solid-specific utilities to use effector/router in your Solid applications: + +- **Route Views** - Connect routes to Solid components +- **Navigation Components** - `Link` and navigation helpers +- **Reactive helpers** - Access router state as Solid accessors +- **Layouts** - Wrap multiple routes with shared layouts + +Unlike the React binding, reactive helpers (`useLink`, `useIsOpened`, `useOpenedViews`) return Solid +**accessors** (`() => value`) instead of plain values. + +## Installation + +```bash +npm install @effector/router-solid @effector/router effector effector-solid solid-js +``` + +## Quick Example + +```tsx +import { createRoute, createRouter } from '@effector/router'; +import { + RouterProvider, + createRouteView, + createRoutesView, + Link, +} from '@effector/router-solid'; + +// 1. Create routes +const homeRoute = createRoute({ path: '/home' }); +const aboutRoute = createRoute({ path: '/about' }); + +// 2. Create router +const router = createRouter({ routes: [homeRoute, aboutRoute] }); + +// 3. Bind routes to components +const HomeScreen = createRouteView({ + route: homeRoute, + view: () => ( +
+

Home

+ Go to About +
+ ), +}); + +const AboutScreen = createRouteView({ + route: aboutRoute, + view: () => ( +
+

About

+ Go to Home +
+ ), +}); + +// 4. Render the currently opened route +const RoutesView = createRoutesView({ + routes: [HomeScreen, AboutScreen], +}); + +// 5. Use in app +function App() { + return ( + + + + ); +} +``` + +## Using Route Parameters + +Access route parameters with effector-solid's `useUnit`, which returns a Solid accessor: + +```tsx +import { useUnit } from 'effector-solid'; + +const userRoute = createRoute({ path: '/user/:id' }); + +const UserScreen = createRouteView({ + route: userRoute, + view: () => { + const params = useUnit(userRoute.$params); + return
User ID: {params().id}
; + }, +}); +``` + +## Lazy Loading + +Load route components on demand: + +```tsx +import { createLazyRouteView } from '@effector/router-solid'; + +const ProfileScreen = createLazyRouteView({ + route: profileRoute, + view: () => import('./screens/ProfileScreen'), + fallback: () =>
Loading...
, +}); +``` + +## Layouts + +Share layouts across multiple routes: + +```tsx +import { withLayout } from '@effector/router-solid'; + +const MainLayout = (props) => ( +
+
Header
+ {props.children} + +
+); + +const RoutesView = createRoutesView({ + routes: [ + ...withLayout(MainLayout, [HomeScreen, AboutScreen, ContactScreen]), + LoginScreen, // Without layout + ], +}); +``` + +## APIs + +The Solid binding mirrors the React one: + +- `RouterProvider` — provide the router to the tree. +- `createRouteView` / `createLazyRouteView` — bind a route to a component (with optional lazy loading). +- `createRoutesView` — render the currently opened route, with an `otherwise` fallback. +- `Link`, `useLink` — declarative and imperative navigation. +- `withLayout` — share a layout across routes. +- `Outlet`, `useRouter`, `useIsOpened`, `useOpenedViews` — composition helpers. + +## Next Steps + +- [Core Package](/core/create-router) - Learn about core concepts +- [React bindings](/react/) - The stable reference implementation this binding mirrors