Changelog
Unreleased
Fixed
RouterWrapperevent-subscription leak (BIG): the previous implementation calledrouterEvents.onRendering(...)inside the render body via an IIFE, which created a new event subscription on every React render and only cleaned up the most recent one — leaking subscriptions under React 18 concurrent rendering andStrictMode’s double-render pass. Rebuilt the component onuseSyncExternalStorewith a single module-level store.subscribeopens at most onerouter.renderingsubscription (refcounted by listener count) and cleans up when the last subscriber detaches;getSnapshotreturns the current{ Layout, content, isLoading }triple. All original side effects (route key generation,router.activeRoutemutation,"rendered"event firing onsetTimeout(0), content caching, middleware execution) are preserved on the store’s mutation path. (src/components/RouterWrapper.tsx)- Matcher pattern-cache invalidation: the module-level
cache: any = {}insrc/matcher.tswas never cleared whenrouter.setMatcher(...)replaced the matcher, so swapping matchers would silently keep using regexps compiled by the previous matcher. The cache is now scoped per matcher reference via aWeakMap<UrlMatcher, ...>, so a new matcher implicitly starts with a fresh cache and old caches become garbage-collectable along with the matcher they belong to. (src/matcher.ts) Math.random().toString(36).substring(7)can return an empty string: occurred atsrc/router.tsx(refreshActiveRouteKey) andsrc/components/RouterWrapper.tsx(updatePage) when the base-36 representation of the random value had fewer than 7 characters. An empty Reactkeydefeats the reconciler’s mount/unmount. Replaced withgenerateRouteKey(), which composes two padded base-36 chunks and is guaranteed non-empty (6–12 chars). (src/router.tsx,src/components/RouterWrapper.tsx)LinkforwardRefgeneric order:forwardRef<LinkProps>(InnerLink) as React.FC<LinkProps>typed the first generic as the ref element (it’s actually the props slot) and the cast hid the mismatch — consumers got an incorrectly typed ref. NowforwardRef<HTMLAnchorElement, LinkProps>(InnerLink); the inner ref-forwarding callback also now handles both object refs and callback refs, where it previously assumed an object ref. (src/components/Link/Link.tsx)- Router constructor SSR crash: the singleton was instantiated at module load and called
window.addEventListener("popstate", ...)plusrouterEvents.onNavigating(...)(viasetScrollToTop("smooth")) unconditionally, makingimport "@mongez/react-router"throw under SSR/Node wherewindowis undefined. The constructor now guards both withtypeof window !== "undefined". Browser behaviour is unchanged — consumers that rely on listeners-being-active-on-import still get that. See Notes below for why the listeners weren’t deferred to astart()call. (src/router.tsx)
Added
- AI kit:
llms.txt,llms-full.txt, andskills/folder (README,overview,routes,navigation,params,lazy-loading,localization,recipes) for tool-assisted development against the public API. - README rewrite: technical reference covering every export, with the configuration table generated from the actual
RouterConfigurationstype. Removed examples that referenced non-exported helpers (e.g.getCurrentLocaleCode,getApps). - Test suite: vitest + happy-dom +
@testing-library/react. Covers route registration, dynamic segment parsing (:id,:id?,:id+,:id*), groups andpartOf, programmatic navigation (navigateTo/navigateBack/silentNavigation/refresh),<Link>rendering and click interception (incl. modifier keys,target="_blank",email/tel/ external URL passthrough), the query-string parser/serializer, the URL pattern matcher, router events, base path, and not-found handling. - CHANGELOG: this file.
- CI: GitHub Actions workflow
Testrunning Node 18/20/22 on Ubuntu, Node 20 on Windows, plus a React 19 cross-matrix entry on Node 20 / Ubuntu. vitest.config.ts: happy-dom environment, React plugin, self-detecting sibling-alias resolver. The aliases for@mongez/concat-routeand@mongez/eventsshort-circuitnode_moduleswhen their source folders exist locally (monorepo dev), and silently fall back to the published packages in CI.package.jsonmetadata:descriptionupdated from “A powerful react router system with lazy loading” to a feature-list-shaped one-liner.keywordsexpanded withrouting,lazy-loading,code-splitting,middleware,i18n,localization,prefetch,chunk-error,react-router.sideEffects: false(enables tree-shaking; the package source has no top-level side effects beyond the singleton instantiation inrouter.tsx, which consumers must opt into by importing the default export anyway).scripts.testandscripts.test:watchfor vitest.devDependenciesfor the vitest stack (vitest,happy-dom,@vitejs/plugin-react,@testing-library/react,@testing-library/dom,react,react-dom,@types/react,@types/react-dom,typescript).
router-wrapper.test.tsx: new test file covering the rebuiltRouterWrapper— subscription-lifecycle assertions (at most onerouter.renderingsubscription regardless of render count, single subscription shared across multiple wrapper instances, tear-down on unmount) and rendering-behavior assertions (initial render of the matched route, re-render in response to a"rendering"event,"rendered"event firing).generateRouteKeytests inrouter.test.ts: explicit coverage of the random-key replacement — non-empty for allMath.random()corner cases (0, tiny, near-1), stable length bounds, andrefreshActiveRouteKeyalways assigning a non-empty key.- Link
forwardReftests inlink.test.tsx: object refs and callback refs both receive the underlyingHTMLAnchorElement. - Matcher cache-invalidation test in
router.test.ts: swapping the matcher viasetMatcherproduces results from the new matcher, not from the prior matcher’s compiled cache.
Changed
None — the public API is identical.
Removed
None.
Notes
The router constructor still calls window.addEventListener("popstate", ...) and routerEvents.onNavigating(...) (via setScrollToTop) at import time when a window is present. That means import "@mongez/react-router" has observable side effects in a browser environment: the singleton subscribes to popstate and scroll-to-top. Tree-shakers will still drop the named exports you don’t use, but the default export drags the singleton in. sideEffects: false is set because consumers always have to opt into the singleton explicitly (via import router from "@mongez/react-router"); tools that follow re-exports treat the singleton init as part of that opt-in.
These listeners were intentionally not deferred to a router.start() call: consumers in the wild may depend on popstate being handled immediately after import (e.g. they wire <Link>s before router.scan() finishes the initial render and expect back/forward to work). Moving to a deferred init would silently break those flows. The fix in this entry adds an SSR guard so the import no longer crashes under Node, but the browser semantics are unchanged.
The RouterWrapper rebuild has one user-visible difference in React.StrictMode (dev only): under StrictMode the previous implementation rendered an empty Fragment between the simulated unmount and remount because the renderer state lived in useState. The new implementation holds the renderer state in a module-level store, so on the remount the previous page is shown immediately instead of a blank flash. The "rendered" event still fires the same number of times. Production rendering (no StrictMode) is identical to the old behaviour.