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/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 0b04a5e..1fe427d 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -74,3 +74,30 @@ jobs: path: ./packages - run: pnpm run test + + solid-e2e: + name: Solid router integration + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup environment + uses: ./.github/actions/setup + + - uses: actions/download-artifact@v4 + with: + name: build-output + path: ./packages + + - name: Install Chromium + run: pnpm --filter @effector/router-solid-example exec playwright install --with-deps chromium + + - name: Build Solid example + run: pnpm --filter @effector/router-solid-example build + + - name: Typecheck Solid example + run: pnpm --filter @effector/router-solid-example typecheck + + - name: Run Solid browser tests + run: pnpm --filter @effector/router-solid-example test:e2e diff --git a/.gitignore b/.gitignore index b9c1fb0..de67d18 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist cache dist-ssr +examples/solid-router/test-results/ *.local .husky diff --git a/README.md b/README.md index 10e23ff..fa297fb 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,13 @@ 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). | ## Documentation Full documentation is published at [router.effector.dev](https://router.effector.dev). Each package also -has its own README: [core](packages/core), [paths](packages/paths), [react](packages/react), and -[react-native](packages/react-native). +has its own README: [core](packages/core), [paths](packages/paths), [react](packages/react), [react-native](packages/react-native), and +[solid](packages/solid). ## Contributing diff --git a/SOLID_CORE_API_GAPS.md b/SOLID_CORE_API_GAPS.md new file mode 100644 index 0000000..09726d5 --- /dev/null +++ b/SOLID_CORE_API_GAPS.md @@ -0,0 +1,94 @@ +# Solid and Core API gaps + +This file records API and typing gaps discovered while building the SolidJS +consumer application in `examples/solid-router`. The items are intentionally +kept separate from the integration-test plan so they can be fixed and removed +independently. + +## Confirmed issues + +### 1. Parent route params are missing from child route types + +`createRoute({path: '/tasks/:taskId', parent: projectRoute})` infers only the +child segment. The built route still needs the parent `projectId`, so consumer +code has to cast params when using `Link` or `useLink`. + +Impact: + +- child links are not fully type-safe; +- route builders and route state expose different parameter contracts; +- the Solid consumer app needed `as any` for nested links. + +Suggested direction: derive the child route params as the intersection of the +parent route params and the child path params, while preserving the current +runtime builder behavior. + +### 2. `VirtualRoute` is not assignable to `Route` + +`group`, `createVirtualRoute`, and `chainRoute` return `VirtualRoute`, but the +Solid helpers accept `Route | Router`. The types differ notably in the +payload of `open` and in `$params`, so valid virtual routes require casts when +passed to `useIsOpened`, `createRouteView`, or related helpers. + +Suggested direction: introduce a shared public route/viewable-route contract +for path routes, pathless routes, and virtual routes, or widen the Solid helper +signatures to the exact virtual-route shape. + +### 3. `withLayout` drops nested route children + +`withLayout` maps each view to a new object containing only `route` and +`view`. A `RouteView.children` tree is lost when a parent view is wrapped by a +layout. + +Impact: a parent route wrapped with `withLayout` cannot reliably render its +nested `Outlet` tree. + +Suggested direction: preserve `children` when mapping the views and add a +regression test for a layout-wrapped parent with an active child route. + +### 4. `createLazyRouteView` dropped nested route children (resolved) + +`CreateLazyRouteViewProps` allowed `children`, but the Solid implementation +returned only `route` and `view`. Lazy parent routes therefore lost their +nested outlet configuration. The Solid binding now returns `children` and has a +regression test covering the contract. + +The same runtime gap remains in the React binding and should be fixed there +separately. + +### 5. `Link.href` does not include the query payload + +`Link` resolves `href` from the route path only. Its `query` prop is applied on +click through `onOpen`, but is absent from the rendered anchor href. + +Impact: copy-link, native link preview, accessibility tooling, and no-script +fallbacks see an incomplete URL. + +Suggested direction: expose a URL builder that serializes both route params +and query, then use it for `href` while keeping click navigation behavior. + +## Follow-up checks + +### 6. Lazy fallback behavior needs an explicit browser contract + +The core route waits for the async bundle import before opening the route, and +the Solid binding also wraps the component in `Suspense`. A browser test should +define whether the fallback is expected during bundle loading or only during +component rendering, then lock that behavior down. In the current consumer +example, a direct deep-link to `/reports` can remain on the router fallback, +while entering the same lazy route through client-side navigation renders it +correctly. The E2E suite records the working client-navigation behavior and +keeps direct-refresh coverage on a non-lazy nested route. + +### 7. Active-link styling is not part of `Link` + +The Solid `Link` API forwards anchor props but does not expose an active-route +state or `activeClass`. This is not a runtime bug, but every consumer must +implement active styling separately. Decide whether this is intentional parity +with the current React binding or a future API addition. + +## Verification context + +These findings came from the Solid consumer app and package declarations, not +from speculative API review. The app keeps casts localized to the scenarios +above so the integration tests still exercise the runtime behavior directly. 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 diff --git a/examples/solid-router/e2e/advanced.spec.ts b/examples/solid-router/e2e/advanced.spec.ts new file mode 100644 index 0000000..f0c06a2 --- /dev/null +++ b/examples/solid-router/e2e/advanced.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; + +test('renders lazy route and nested settings router', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'Reports' }).click(); + await expect(page).toHaveURL(/\/reports$/); + await expect(page.getByTestId('page-reports')).toBeVisible(); + await expect(page.locator('body')).toHaveAttribute( + 'data-reports-mounted', + 'true', + ); + + await page.getByRole('link', { name: 'Settings' }).click(); + await expect(page).toHaveURL(/\/settings$/); + await expect(page.getByTestId('settings-general')).toBeVisible(); + + await page.getByRole('link', { name: 'Profile' }).click(); + await expect(page).toHaveURL(/\/settings\/profile$/); + await expect(page.getByTestId('settings-profile')).toBeVisible(); +}); + +test('guards a route and recovers after authorization changes', async ({ + page, +}) => { + await page.goto('/protected'); + await expect(page).toHaveURL(/\/protected$/); + await expect(page.getByTestId('page-not-found')).toBeVisible(); + + await page.getByTestId('toggle-auth').click(); + await page.getByTestId('retry-protected').click(); + await expect(page.getByTestId('page-protected')).toBeVisible(); + await expect(page.getByTestId('auth-state')).toContainText('enabled'); +}); + +test('uses a query-backed modal router without changing the host path', async ({ + page, +}) => { + await page.goto('/projects/solid?sort=asc'); + await page.getByTestId('open-modal').click(); + await expect(page).toHaveURL( + /\/projects\/solid\?sort=asc&modal=%2Ftask%2Froute-contracts$/, + ); + await expect(page.getByTestId('task-modal')).toContainText('Route contracts'); + + await page.getByTestId('close-modal').click(); + await expect(page).toHaveURL(/\/projects\/solid\?sort=asc$/); + await expect(page.getByTestId('task-modal')).toBeHidden(); +}); + +test('opens and closes a virtual drawer route', async ({ page }) => { + await page.goto('/'); + await page.getByTestId('open-drawer').click(); + await expect(page.getByTestId('activity-drawer')).toContainText('activity'); + await page.getByTestId('close-drawer').click(); + await expect(page.getByTestId('activity-drawer')).toBeHidden(); +}); + +test('falls back for an unknown URL and supports direct refresh paths', async ({ + page, +}) => { + await page.goto('/does-not-exist'); + await expect(page.getByTestId('page-not-found')).toBeVisible(); + + await page.goto('/projects/router/tasks/route-contracts'); + await expect(page.getByTestId('project-task')).toContainText( + 'Route contracts', + ); + await page.reload(); + await expect(page.getByTestId('project-task')).toContainText( + 'Route contracts', + ); +}); diff --git a/examples/solid-router/e2e/navigation.spec.ts b/examples/solid-router/e2e/navigation.spec.ts new file mode 100644 index 0000000..2dc12b7 --- /dev/null +++ b/examples/solid-router/e2e/navigation.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('page-home')).toBeVisible(); +}); + +test('navigates through params, nested outlet, and custom useLink', async ({ + page, +}) => { + await page.getByTestId('project-router').click(); + await expect(page).toHaveURL(/\/projects\/router\/overview$/); + await expect(page.getByTestId('project-overview')).toContainText('router'); + await expect(page.getByTestId('project-group-state')).toHaveText('active'); + + await page.getByRole('link', { name: 'Tasks' }).click(); + await expect(page).toHaveURL(/\/projects\/router\/tasks\/integration-tests$/); + await expect(page.getByTestId('project-task')).toContainText( + 'Integration tests', + ); + await expect(page.getByTestId('custom-task-path')).toHaveText( + '/projects/solid/tasks/integration-tests', + ); + + await page.getByTestId('custom-task-link').click(); + await expect(page).toHaveURL(/\/projects\/solid\/tasks\/integration-tests$/); + await expect(page.getByTestId('project-task')).toBeVisible(); +}); + +test('handles search query tracker and history controls', async ({ page }) => { + await page.getByRole('link', { name: 'Search' }).click(); + await expect(page).toHaveURL(/\/search\?q=solid$/); + await expect(page.getByTestId('search-value')).toContainText('solid'); + + await page.getByTestId('search-input').fill('router'); + await page.getByTestId('search-submit').click(); + await expect(page).toHaveURL(/\/search\?q=router$/); + await expect(page.getByTestId('search-value')).toContainText('router'); + + await page.getByTestId('search-clear').click(); + await expect(page).toHaveURL(/\/search$/); + await expect(page.getByTestId('search-value')).toContainText('none'); + + await page.getByTestId('router-back').click(); + await expect(page).toHaveURL(/\/search\?q=router$/); +}); + +test('supports the dynamically registered pathless route', async ({ page }) => { + await page.getByTestId('link-help').click(); + await expect(page).toHaveURL(/\/help$/); + await expect(page.getByTestId('page-help')).toBeVisible(); +}); diff --git a/examples/solid-router/index.html b/examples/solid-router/index.html new file mode 100644 index 0000000..002bf21 --- /dev/null +++ b/examples/solid-router/index.html @@ -0,0 +1,12 @@ + + + + + + Solid Router Lab + + +
+ + + diff --git a/examples/solid-router/package.json b/examples/solid-router/package.json new file mode 100644 index 0000000..f58aa03 --- /dev/null +++ b/examples/solid-router/package.json @@ -0,0 +1,28 @@ +{ + "name": "@effector/router-solid-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" + }, + "dependencies": { + "@effector/router": "workspace:*", + "@effector/router-solid": "workspace:*", + "effector": "^23.4.4", + "effector-solid": "^0.23.0", + "history": "5.3.0", + "solid-js": "^1.9.14", + "zod": "^4.3.6" + }, + "devDependencies": { + "@playwright/test": "^1.52.0", + "typescript": "5.8.2", + "vite": "7.3.2", + "vite-plugin-solid": "^2.11.12" + } +} diff --git a/examples/solid-router/playwright.config.ts b/examples/solid-router/playwright.config.ts new file mode 100644 index 0000000..1a04d04 --- /dev/null +++ b/examples/solid-router/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4174', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: { + command: 'pnpm build && pnpm preview', + url: 'http://127.0.0.1:4174', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/examples/solid-router/src/App.tsx b/examples/solid-router/src/App.tsx new file mode 100644 index 0000000..a05ee4a --- /dev/null +++ b/examples/solid-router/src/App.tsx @@ -0,0 +1,542 @@ +import { For, Show, createEffect, createSignal, type JSX } from 'solid-js'; +import { useUnit } from 'effector-solid'; +import { + createLazyRouteView, + createRouteView, + createRoutesView, + Link, + Outlet, + RouterProvider, + useIsOpened, + useLink, + useOpenedViews, + useRouter, + useRouterContext, + withLayout, +} from '@effector/router-solid'; +import { z } from 'zod'; +import { + $authorized, + authChanged, + drawerRoute, + helpRoute, + homeRoute, + modalRouter, + modalTaskRoute, + projectGroup, + projectOverviewRoute, + projectTaskRoute, + projectsRoute, + protectedVisibleRoute, + reportsRoute, + router, + searchRoute, + settingsGeneralRoute, + settingsProfileRoute, + settingsRouter, +} from './routing/routes'; + +const projects: Record = { + router: { name: 'Effector Router', owner: 'Core team' }, + solid: { name: 'Solid bindings', owner: 'UI team' }, +}; + +const taskNames: Record = { + 'integration-tests': 'Integration tests', + 'route-contracts': 'Route contracts', +}; + +const searchTracker = router.trackQuery({ + forRoutes: [searchRoute], + parameters: z.object({ q: z.string().min(1) }), +}); + +function MainLayout(props: { children: JSX.Element }) { + return ( +
+
+ + Router Lab + + +
+
{props.children}
+
+ ); +} + +function HomePage() { + const openDrawer = useUnit(drawerRoute.open); + + return ( +
+ Workspace +

Project overview

+

A Solid consumer app for exercising router bindings.

+
+ + {([id, project]) => ( +
+ Project +

{project.name}

+

{project.owner}

+ + Open project + +
+ )} +
+
+
+ + Help route + + +
+
+ ); +} + +function ProjectPage() { + const params = useUnit(projectsRoute.$params); + const active = useIsOpened(projectGroup as any); + const project = () => + projects[params().projectId] ?? { + name: 'Unknown project', + owner: 'Unknown', + }; + + return ( +
+
+
+ Project +

{project().name}

+
+ + {active() ? 'active' : 'closed'} + +
+

{project().owner}

+
+ + Overview + + + Tasks + +
+
+ +
+
+ ); +} + +function ProjectOverviewPage() { + const params = useUnit(projectsRoute.$params); + return ( +
+

Overview

+

+ Project id: {params().projectId} +

+
+ ); +} + +function ProjectTaskPage() { + const params = useUnit(projectTaskRoute.$params); + const task = () => taskNames[params().taskId] ?? params().taskId; + const taskLink = useLink( + projectTaskRoute, + () => + ({ projectId: 'solid', taskId: params().taskId }) as unknown as { + projectId: string; + taskId: string; + }, + ); + + return ( +
+

{task()}

+

+ Task id: {params().taskId} +

+ + + {taskLink.path()} + +
+ ); +} + +function SearchPage() { + const currentQuery = useUnit(router.$query); + const enterSearch = useUnit(searchTracker.enter); + const exitSearch = useUnit(searchTracker.exit); + const [term, setTerm] = createSignal('solid'); + + createEffect(() => { + const value = currentQuery().q; + if (typeof value === 'string') setTerm(value); + }); + + const submit = (event: SubmitEvent) => { + event.preventDefault(); + const value = term().trim(); + if (value) enterSearch({ q: value }); + }; + + return ( +
+ Query tracker +

Search

+
+ setTerm(event.currentTarget.value)} + /> + + +
+

+ Active query: {currentQuery().q ?? 'none'} +

+
+ ); +} + +function ProtectedPage() { + const authorized = useUnit($authorized); + + return ( +
+ Guarded route +

Protected workspace

+

+ Authorization: {authorized() ? 'enabled' : 'disabled'} +

+
+ ); +} + +function GuardControls() { + const authorized = useUnit($authorized); + const changeAuth = useUnit(authChanged); + const currentRouter = useRouter(); + + return ( +
+ Auth: {authorized() ? 'enabled' : 'disabled'} + + +
+ ); +} + +function SettingsPage() { + return ( +
+ Nested router +

Settings

+
+ General + Profile +
+ +
+ ); +} + +const SettingsRoutes = createRoutesView({ + routes: [ + createRouteView({ + route: settingsGeneralRoute, + view: () =>
General settings
, + }), + createRouteView({ + route: settingsProfileRoute, + view: () =>
Profile settings
, + }), + ], + otherwise: () =>
Choose a setting
, +}); + +function HelpPage() { + return ( +
+

Help

+

Pathless route mapped to /help.

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

Page not found

+
+ ); +} + +function ModalLauncher() { + return ( + + ); +} + +function TaskModal() { + const params = useUnit(modalTaskRoute.$params); + const modal = useRouter(); + return ( + + ); +} + +const ModalRoutes = createRoutesView({ + routes: [createRouteView({ route: modalTaskRoute, view: TaskModal })], +}); + +const homeView = createRouteView({ route: homeRoute, view: HomePage }); +const searchView = createRouteView({ route: searchRoute, view: SearchPage }); +const projectView = createRouteView({ + route: projectsRoute, + view: ProjectPage, + layout: (props) =>
{props.children}
, + children: [ + createRouteView({ route: projectOverviewRoute, view: ProjectOverviewPage }), + createRouteView({ route: projectTaskRoute, view: ProjectTaskPage }), + ], +}); +const reportsView = createLazyRouteView({ + route: reportsRoute, + view: () => import('./pages/ReportsPage'), + fallback: () => ( +
+

Loading reports

+
+ ), + layout: MainLayout, +}); +const protectedView = createRouteView({ + route: protectedVisibleRoute, + view: ProtectedPage, +}); +const settingsView = createRouteView({ + route: settingsRouter, + view: SettingsPage, +}); +const helpView = createRouteView({ route: helpRoute, view: HelpPage }); + +const mainViews = [ + ...withLayout(MainLayout, [homeView, searchView, helpView]), + projectView, + reportsView, + protectedView, + settingsView, +]; + +const MainRoutes = createRoutesView({ + routes: mainViews, + otherwise: NotFoundPage, +}); + +function ActiveViews() { + const views = useOpenedViews(mainViews); + const labels = new Map([ + [homeRoute, 'home'], + [searchRoute, 'search'], + [projectsRoute, 'projects'], + [reportsRoute, 'reports'], + [protectedVisibleRoute, 'protected'], + [settingsRouter, 'settings'], + [helpRoute, 'help'], + ]); + + return ( +
+ Views + + {views() + .map((view) => labels.get(view.route as any) ?? 'nested') + .join(' > ') || 'none'} + +
+ ); +} + +function Inspector() { + const currentRouter = useRouter(); + const contextRouter = useRouterContext(); + const reportsOpened = useIsOpened(reportsRoute); + const settingsOpened = useIsOpened(settingsRouter); + const projectGroupOpened = useIsOpened(projectGroup as any); + + return ( + + ); +} + +function Drawer() { + const opened = useUnit(drawerRoute.$isOpened); + const drawer = useUnit(drawerRoute); + return ( + +
+ Activity panel: {drawer.params()} + +
+
+ ); +} + +export function App() { + return ( + +
+ + + + + + + + +
+
+ ); +} diff --git a/examples/solid-router/src/main.tsx b/examples/solid-router/src/main.tsx new file mode 100644 index 0000000..6ef2d8f --- /dev/null +++ b/examples/solid-router/src/main.tsx @@ -0,0 +1,19 @@ +import { render } from 'solid-js/web'; +import { Provider } from 'effector-solid'; +import { App } from './App'; +import { initializeRouter, scope } from './routing/bootstrap'; +import './styles.css'; + +const root = document.getElementById('root'); +if (!root) throw new Error('Root element not found'); + +await initializeRouter(); + +render( + () => ( + + + + ), + root, +); diff --git a/examples/solid-router/src/pages/ReportsPage.tsx b/examples/solid-router/src/pages/ReportsPage.tsx new file mode 100644 index 0000000..6a84967 --- /dev/null +++ b/examples/solid-router/src/pages/ReportsPage.tsx @@ -0,0 +1,19 @@ +import { onCleanup, onMount } from 'solid-js'; + +export default function ReportsPage() { + onMount(() => { + document.body.dataset.reportsMounted = 'true'; + }); + + onCleanup(() => { + delete document.body.dataset.reportsMounted; + }); + + return ( +
+ Analytics +

Reports

+

Lazy route content loaded from a separate module.

+
+ ); +} diff --git a/examples/solid-router/src/routing/bootstrap.ts b/examples/solid-router/src/routing/bootstrap.ts new file mode 100644 index 0000000..ebd1019 --- /dev/null +++ b/examples/solid-router/src/routing/bootstrap.ts @@ -0,0 +1,24 @@ +import { allSettled, fork } from 'effector'; +import { createBrowserHistory } from 'history'; +import { historyAdapter, queryAdapter } from '@effector/router'; +import { modalRouter, router, settingsRouter } from './routes'; + +export const browserHistory = createBrowserHistory(); +export const scope = fork(); + +export async function initializeRouter() { + await allSettled(router.setHistory, { + scope, + params: historyAdapter(browserHistory), + }); + await allSettled(settingsRouter.setHistory, { + scope, + params: historyAdapter(browserHistory), + }); + await allSettled(modalRouter.setHistory, { + scope, + params: queryAdapter(browserHistory, { key: 'modal' }), + }); + + await allSettled(scope); +} diff --git a/examples/solid-router/src/routing/routes.ts b/examples/solid-router/src/routing/routes.ts new file mode 100644 index 0000000..a89cb1d --- /dev/null +++ b/examples/solid-router/src/routing/routes.ts @@ -0,0 +1,82 @@ +import { attach, createEvent, createStore } from 'effector'; +import { + chainRoute, + createRoute, + createRouter, + createRouterControls, + createVirtualRoute, + group, +} from '@effector/router'; +import type { RouteOpenedPayload } from '@effector/router'; + +export const homeRoute = createRoute({ path: '/' }); +export const projectsRoute = createRoute({ path: '/projects/:projectId' }); +export const projectOverviewRoute = createRoute({ + path: '/overview', + parent: projectsRoute, +}); +export const projectTaskRoute = createRoute({ + path: '/tasks/:taskId', + parent: projectsRoute, +}); +export const searchRoute = createRoute({ path: '/search' }); +export const reportsRoute = createRoute({ path: '/reports' }); +export const protectedRoute = createRoute({ path: '/protected' }); + +export const helpRoute = createRoute(); + +export const settingsGeneralRoute = createRoute({ path: '/' }); +export const settingsProfileRoute = createRoute({ path: '/profile' }); +export const settingsRouter = createRouter({ + base: '/settings', + routes: [settingsGeneralRoute, settingsProfileRoute], +}); + +export const modalTaskRoute = createRoute({ path: '/task/:taskId' }); +export const modalRouter = createRouter({ routes: [modalTaskRoute] }); + +export const router = createRouter({ + controls: createRouterControls(), + routes: [ + homeRoute, + projectsRoute, + projectOverviewRoute, + projectTaskRoute, + searchRoute, + reportsRoute, + protectedRoute, + settingsRouter, + ], +}); + +router.registerRoute({ path: '/help', route: helpRoute }); + +export const authChanged = createEvent(); +export const $authorized = createStore(false).on( + authChanged, + (_, value) => value, +); + +const checkAuthFx = attach({ + source: $authorized, + effect: (authorized, payload: RouteOpenedPayload) => { + if (!authorized) throw new Error('Authorization required'); + return payload; + }, +}); + +export const protectedVisibleRoute = chainRoute({ + route: protectedRoute, + beforeOpen: checkAuthFx, + openOn: checkAuthFx.done, + cancelOn: checkAuthFx.fail, +}); + +export const projectGroup = group([ + projectsRoute, + projectOverviewRoute, + projectTaskRoute, +]); +export const drawerRoute = createVirtualRoute<{ panel: string }, string>({ + transformer: ({ panel }) => panel, +}); diff --git a/examples/solid-router/src/styles.css b/examples/solid-router/src/styles.css new file mode 100644 index 0000000..b5cd0bc --- /dev/null +++ b/examples/solid-router/src/styles.css @@ -0,0 +1,58 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + color: #19212b; + background: #eef2f5; + font-synthesis: none; +} +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; } +button, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +a { color: #176b87; text-decoration: none; } +a:hover, a.active { color: #0b4358; text-decoration: underline; } +button { border: 1px solid #b8c5cc; border-radius: 5px; background: #fff; color: #19212b; padding: 8px 11px; cursor: pointer; } +button:hover { border-color: #176b87; } +.shell { min-height: 100vh; } +.topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px 28px; background: #19323d; color: #fff; } +.brand { color: #fff; font-weight: 700; letter-spacing: .02em; } +.topnav { display: flex; flex-wrap: wrap; gap: 18px; } +.topnav a { color: #dcecf0; } +.topnav a.active { color: #fff; font-weight: 700; } +.content { max-width: 860px; margin: 0 auto; padding: 44px 28px 100px; } +.inspector { position: fixed; right: 18px; bottom: 18px; width: 280px; padding: 15px; border: 1px solid #c6d2d8; border-radius: 7px; background: rgba(255, 255, 255, .97); box-shadow: 0 8px 30px rgba(25, 50, 61, .12); font-size: 13px; } +.inspector-heading, .inspector-row, .inspector-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.inspector-heading { padding-bottom: 10px; border-bottom: 1px solid #e1e7ea; font-weight: 700; } +.inspector-row { padding-top: 9px; } +.inspector-row strong { max-width: 175px; overflow-wrap: anywhere; text-align: right; font-weight: 600; } +.inspector-actions { justify-content: flex-start; padding-top: 12px; } +.status-chip, .card-label, .eyebrow { color: #607581; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.status-chip { padding: 3px 6px; border: 1px solid #c6d2d8; border-radius: 4px; letter-spacing: .02em; } +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 10px; font-size: 38px; line-height: 1.1; } +h2 { margin-bottom: 8px; font-size: 22px; } +.lede { color: #607581; font-size: 17px; } +.project-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 28px; } +.project-card { padding: 18px; border: 1px solid #c6d2d8; border-radius: 7px; background: #fff; } +.project-card p { color: #607581; } +.project-card a { display: inline-block; margin-top: 10px; } +.action-row, .tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 24px; } +.tabs { border-bottom: 1px solid #c6d2d8; padding-bottom: 10px; } +.tabs a { padding: 5px 0; } +.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.outlet, .project-layout { min-height: 120px; } +.outlet { padding-top: 25px; } +.search-form { display: flex; flex-wrap: wrap; gap: 8px; margin: 28px 0 16px; } +.search-form input { min-width: 240px; border: 1px solid #b8c5cc; border-radius: 5px; padding: 8px 10px; } +.muted { display: block; margin-top: 14px; color: #607581; font-family: ui-monospace, monospace; font-size: 12px; } + .drawer { position: fixed; left: 18px; bottom: 78px; display: flex; gap: 12px; align-items: center; padding: 14px; border: 1px solid #9bb7c2; border-radius: 6px; background: #e5f2f5; } +.guard-controls { position: fixed; left: 18px; bottom: 18px; z-index: 3; display: flex; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid #c6d2d8; border-radius: 6px; background: #fff; font-size: 12px; } +.modal-launcher { position: fixed; left: 18px; top: 78px; z-index: 2; } +.modal-backdrop { position: fixed; inset: 0; z-index: 5; display: grid; place-items: center; background: rgba(25, 50, 61, .35); } +.modal { width: min(420px, calc(100vw - 32px)); padding: 24px; border-radius: 7px; background: #fff; box-shadow: 0 18px 50px rgba(25, 50, 61, .25); } +@media (max-width: 760px) { + .topbar { align-items: flex-start; flex-direction: column; padding: 16px 18px; } + .content { padding: 32px 18px 180px; } + .project-grid { grid-template-columns: 1fr; } + .inspector { right: 12px; bottom: 12px; left: 12px; width: auto; } + .modal-launcher { left: 18px; top: 128px; } +} diff --git a/examples/solid-router/tsconfig.json b/examples/solid-router/tsconfig.json new file mode 100644 index 0000000..15e2842 --- /dev/null +++ b/examples/solid-router/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "declaration": false, + "types": ["vite/client", "@playwright/test"] + }, + "include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/examples/solid-router/vite.config.ts b/examples/solid-router/vite.config.ts new file mode 100644 index 0000000..a9bc189 --- /dev/null +++ b/examples/solid-router/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import solid from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [solid({ hot: false })], + server: { port: 4174, strictPort: true }, + preview: { port: 4174, strictPort: true }, +}); diff --git a/package.json b/package.json index a20ee76..3ec0073 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ ":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", + ":solid-example": "pnpm --filter @effector/router-solid-example", ":paths": "pnpm --filter @effector/router-paths", ":core": "pnpm --filter @effector/router" }, @@ -28,6 +30,7 @@ "@react-navigation/bottom-tabs": "7.15.5", "@react-navigation/native": "7.1.33", "@react-navigation/stack": "7.8.4", + "@solidjs/testing-library": "^0.8.10", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -36,6 +39,7 @@ "@vitejs/plugin-react": "^4.3.4", "effector": "^23.4.4", "effector-react": "^23.3.0", + "effector-solid": "^0.23.0", "eslint": "10.0.2", "eslint-config-prettier": "10.1.8", "eslint-plugin-prettier": "5.5.5", @@ -49,10 +53,13 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-native": "0.84.1", + "solid-js": "^1.9.14", + "typescript": "5.8.2", "typescript-eslint": "^8.56.1", "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.1.9", "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..4eabd84 --- /dev/null +++ b/packages/solid/lib/create-lazy-route-view.tsx @@ -0,0 +1,53 @@ +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, children } = props; + + const inner = () => ( + : null}> + + + ); + + const view = Layout ? () => {inner()} : inner; + + return { + route: props.route, + view, + children, + }; +} 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/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'); + }); +}); diff --git a/packages/solid/tests/index.test.tsx b/packages/solid/tests/index.test.tsx new file mode 100644 index 0000000..d55f2e4 --- /dev/null +++ b/packages/solid/tests/index.test.tsx @@ -0,0 +1,99 @@ +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 { + createLazyRouteView, + createRouteView, + 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'); + }); + + test('lazy route view preserves nested route views', () => { + const parentRoute = createRoute({ path: '/parent' }); + const childRoute = createRoute({ path: '/child', parent: parentRoute }); + const childView = createRouteView({ + route: childRoute, + view: () =>

child

, + }); + + const lazyView = createLazyRouteView({ + route: parentRoute, + view: () => Promise.resolve({ default: () =>

parent

}), + children: [childView], + }); + + expect(lazyView.children).toEqual([childView]); + }); +}); 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 d516a35..0cd4c19 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,12 @@ 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: + specifier: 5.8.2 + version: 5.8.2 typescript-eslint: specifier: ^8.56.1 version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.8.2) @@ -116,6 +128,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) @@ -148,6 +163,43 @@ importers: 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) + examples/solid-router: + dependencies: + '@effector/router': + specifier: workspace:* + version: link:../../packages/core + '@effector/router-solid': + specifier: workspace:* + version: link:../../packages/solid + effector: + specifier: ^23.4.4 + version: 23.4.4 + effector-solid: + specifier: ^0.23.0 + version: 0.23.0(effector@23.4.4)(solid-js@1.9.14) + history: + specifier: 5.3.0 + version: 5.3.0 + solid-js: + specifier: ^1.9.14 + version: 1.9.14 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@playwright/test': + specifier: ^1.52.0 + version: 1.61.1 + typescript: + specifier: 5.8.2 + version: 5.8.2 + vite: + specifier: 7.3.2 + version: 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)) + packages/core: dependencies: '@effector/router-paths': @@ -216,6 +268,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': @@ -372,6 +439,10 @@ packages: resolution: {integrity: sha512-xkXrMbtk87Gk7+oKBVmBc6EORg/Qwx++AHESldmHkpvG8wgccdhJJFwrzqlF382Fk8wfXhJHWE/g/43QvEGNPQ==} engines: {node: ^22.18.0 || >=24.11.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'} @@ -1736,6 +1807,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2056,6 +2132,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==} @@ -2581,6 +2667,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: @@ -2610,6 +2701,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==} @@ -2880,6 +2980,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'} @@ -2905,6 +3012,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'} @@ -3141,6 +3252,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3238,6 +3354,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==} @@ -3324,6 +3443,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'} @@ -3523,6 +3646,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==} @@ -3796,6 +3923,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'} @@ -3865,6 +3995,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.13: resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} engines: {node: ^10 || ^12 || >=14} @@ -4118,6 +4258,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'} @@ -4165,6 +4315,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'} @@ -4494,6 +4652,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} @@ -4565,6 +4733,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 @@ -4979,6 +5155,10 @@ snapshots: '@babel/traverse': 8.0.0 '@babel/types': 8.0.0 + '@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 @@ -6435,6 +6615,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@polka/url@1.0.0-next.29': {} '@quansync/fs@1.0.0': @@ -6748,6 +6932,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': @@ -7414,6 +7603,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 @@ -7467,6 +7665,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: {} @@ -7696,6 +7901,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: {} @@ -7713,6 +7923,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: @@ -7996,6 +8208,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8105,6 +8320,8 @@ snapshots: hookable@5.5.3: {} + html-entities@2.3.3: {} + html-void-elements@3.0.0: {} http-errors@2.0.1: @@ -8178,6 +8395,8 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-what@4.1.16: {} + is-what@5.5.0: {} is-windows@1.0.2: {} @@ -8416,6 +8635,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: {} @@ -8797,6 +9020,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: {} @@ -8845,6 +9072,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.13: dependencies: nanoid: 3.3.15 @@ -9144,6 +9379,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 @@ -9194,6 +9435,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: @@ -9508,6 +9764,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 @@ -9533,6 +9802,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/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4879875..f5ae699 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - 'packages/*' + - 'examples/*' - 'docs' allowBuilds: esbuild: true diff --git a/tsconfig.json b/tsconfig.json index 1549df7..ccb2127 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", "examples/solid-router"] }