Skip to content

fix(deps)!: move react-router to peerDependencies (^8.3.0) - #465

Draft
interacsean wants to merge 2 commits into
mainfrom
fix/react-router-peer-dependency
Draft

fix(deps)!: move react-router to peerDependencies (^8.3.0)#465
interacsean wants to merge 2 commits into
mainfrom
fix/react-router-peer-dependency

Conversation

@interacsean

@interacsean interacsean commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes tailor-inc/platform-planning#1721

Moves react-router from dependencies to peerDependencies at ^8.3.0, keeping it as a devDependency for app-shell's own build and tests. Plus a migrations.md entry and a quickstart note.

Draft — the semver call and the CI question below want a decision first.

Why

react-router carries React context, and context identity is per module instance, so app-shell and the app it powers must share one copy. As a regular dependency that was never guaranteed: whenever an app's resolved version differed from app-shell's, both got installed and the two routers were disjoint.

The failure is asymmetric, which is why it went unnoticed for so long: app-shell keeps working against its own copy — sidebar, breadcrumbs, navigation all fine — while the app's own useNavigate / useLocation / <Link> throw may be used only in the context of a <Router> component. TypeScript cannot see it, so builds pass. It is also time-triggered rather than configuration-triggered: a new react-router release can shift one range's resolution and not the other's, with no code change on either side.

#412 addressed this by matching majors and documenting "import routing primitives from app-shell". A reviewer noted at the time that there were still no guardrails keeping an app's version in sync, and it was deferred. This is that guardrail — and it has since been reproduced on a real consumer at the exact version pair measured below.

Evidence

Measured in a faithful external consumer: a Vite app outside this monorepo installing app-shell from a pnpm pack tarball with its own react-router, AppShell mounted with basePath="app" so basename handling is exercised too. This matters because the in-repo examples resolve react-router through a workspace link in a way no real consumer does — and the monorepo dev server is misleadingly forgiving, passing while vite build produced two copies.

config bundled copies runtime
dependencies, app on a different 8.x two app content dead in dev; app-shell chrome fine
peerDependencies, app on 8.x one (the app's) all checks pass

Under peer deps the app's version always wins — app-shell stops choosing its own router version. Behavioural checks, not just absence of throws: location agreement across both import paths, basename resolution through consumer <Link>, useParams on an app-shell route, setSearchParams visible to app-shell, app-shell's redirectTo guard firing on app-initiated navigation, chrome following app navigation, history.back() after mixed navigation — and consumer useBlocker intercepting app-shell-initiated navigation, which is only possible on one shared router.

Why not just document resolve.dedupe: it does collapse the bundle, and works under dependencies too, but nothing obliges an app to set it, it must cover react-router and react-router/dom, and it can bind app-shell to a major it was never compiled against — measured: with dedupe on and the app on react-router 7, app-shell built against 8.3.0 silently ran on 7.18.2.

Impact by what an app declares today

declares today today after
nothing rides app-shell's version peer auto-installed. pnpm: not top-level, so the app's own react-router imports fail at build until declared
^8.3.0, or any range resolving to the same version one copy by coincidence one copy by construction
a resolved 8.x other than app-shell's (e.g. pinned 8.2.0) two copies — the app's own router usage breaks one copy, works
react-router 7 two copies, broken npm ERESOLVE / pnpm unmet-peer warning — needs the 7→8 upgrade in the same change

The "declares nothing" row is the one most likely to surprise: worth grepping non-pinning frontends for direct react-router imports before release. Apps pinned to v7 are already broken on 1.11.0–1.12.0; the install message is how they find out.

Security is unaffected: every published react-router advisory has a patch inside the v7 line and ^8.3.0 sits above all of them, so the alert-clearing outcome of #412 is preserved. Peer deps also shrink app-shell's own alert surface, since react-router stops being a runtime dependency.

Verification

pnpm build, pnpm type-check, pnpm lint, pnpm test (81 files, 1570 tests) pass. Both example apps build.

Open questions

  1. Semver — currently minor (1.13.0), provisional pending a team discussion. For minor: most apps already declare react-router 8, and both package managers auto-install when an app declares nothing. For major: it changes the install contract, and v7-pinned apps hit a hard ERESOLVE. Note a minor means apps on ^1.12.0 pick this up on a routine update rather than opting in.
  2. CI doesn't exercise the peer contract. app-shell tests against its own devDependency only, so nothing would catch it if a future change started relying on an API outside the declared range. A job installing the packed tarball into a scratch consumer would close that.
  3. Should the example apps declare react-router explicitly? They don't today and still build, since core's devDependency satisfies the peer through the workspace link. Declaring it would model the correct consumer pattern.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Code Metrics Report

main (fdf7e6a) #465 (665928c) +/-
Coverage 90.0% 90.0% 0.0%
Test Execution Time 2m0s 2m3s +3s
Details
  |                     | main (fdf7e6a) | #465 (665928c) | +/-  |
  |---------------------|----------------|----------------|------|
  | Coverage            |          90.0% |          90.0% | 0.0% |
  |   Files             |            126 |            126 |    0 |
  |   Lines             |           5174 |           5174 |    0 |
  |   Covered           |           4661 |           4661 |    0 |
- | Test Execution Time |           2m0s |           2m3s |  +3s |

Code coverage of files in pull request scope (0.0% → 0.0%)

Files Coverage +/- Status
packages/core/package.json 0.0% 0.0% modified

Reported by octocov

@interacsean interacsean changed the title fix(deps)!: move react-router to peerDependencies (^7.18.2 || ^8.0.0) fix(deps)!: move react-router to peerDependencies (^8.3.0) Aug 24, 2026
react-router carries React context, and context identity is per module
instance, so app-shell and the app it powers must share one copy. As a regular
dependency that was never guaranteed: whenever an app's resolved react-router
differed from app-shell's, both were installed and the two routers were
disjoint.

The failure is asymmetric, which is why it went unnoticed: app-shell keeps
working against its own copy — sidebar, breadcrumbs, navigation all fine —
while the app's own useNavigate / useLocation / <Link> throw "may be used only
in the context of a <Router> component". TypeScript cannot see it, so builds
pass. It is also time-triggered rather than configuration-triggered: a new
react-router release can shift one range's resolution and not the other's with
no code change on either side.

#412 addressed this by matching majors and documenting "import routing
primitives from app-shell". A reviewer noted at the time that there were still
no guardrails keeping an app's version in sync, and it was deferred. This is
that guardrail.

The range is a single major on purpose: app-shell builds and tests only against
its ^8.3.0 devDependency, so a wider range would claim support CI never
exercises. ^8.3.0 also sits above every published react-router advisory,
preserving the alert-clearing outcome of #412.

Also updates the routing docs, which previously told consumers not to install
or import react-router — advice this change inverts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@interacsean
interacsean force-pushed the fix/react-router-peer-dependency branch from b295927 to 505ab04 Compare August 24, 2026 06:40
@IzumiSy

IzumiSy commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Thanks for investigating this — I understand the duplicate-instance problem and why making react-router a peer dependency guarantees a single router instance.

My hesitation is more about the product/API boundary this creates. I do not want consumers of @tailor-platform/app-shell to have to be aware of libraries that AppShell uses internally, and react-router falls into that category for me. AppShell was intentionally designed as an all-in-one package, with routing encapsulated inside it rather than exposed as something users need to reason about directly. In the ideal end state, installing @tailor-platform/app-shell alone should be enough to get a working app. Requiring consumers to also install and manage react-router makes an internal implementation detail part of the public setup contract, which feels like a step away from that goal.

I feel similarly about the security-advisory motivation. If a consumer sees an advisory, uses AI or manual inspection to figure out that AppShell happens to use react-router internally, and then installs or upgrades it separately to address that warning, I don’t think that is the contract we want. For issues in AppShell’s internal dependency graph, consumers should generally be able to wait for an AppShell-side update rather than having to discover, understand, and manage those internals themselves.

There is also a support-surface concern here. Once react-router becomes a peer dependency, consumers are much more likely to use it directly in application code. At that point, we start inheriting compatibility expectations around parts of the react-router API that AppShell does not explicitly design for, expose, or validate. That expands the effective surface area we are expected to support. I’d prefer us to keep that dependency boundary as narrow and controlled as possible, and to expose only the routing primitives that we intentionally want to support through AppShell itself.

I’d be fine with leaving an escape hatch for advanced cases — for example, allowing consumers to pin or override AppShell’s transitive react-router version at the package-manager level when they have a specific reason to do so. Since we use pnpm, that could be done via an override in pnpm-workspace.yaml, e.g.:

overrides:
  "@tailor-platform/app-shell>react-router": "8.3.0"

That kind of explicit opt-in escape hatch seems fine to me. What I want to avoid is making react-router part of the default, required setup contract for every AppShell consumer.

So while I agree that the duplicate-router issue is real, I’d prefer us to look for a solution that preserves the “AppShell owns routing” model if possible, rather than making react-router part of the consumer-facing install and maintenance story.

@interacsean

Copy link
Copy Markdown
Contributor Author

Yes I suppose the issue this tries to fix is only valid if the consumer application has react-router installed as dependency to try to achieve some customization that is not possible with what app-shell re-exports. And adding peer-dependency nudges consumers that may not otherwise have RR as a dependency to install it.

Some more thoughts after reading your comment...

My first instinct to cover what you are getting at is to re-export everything that react router does, so that users would have no need to install RR in their dependencies, and struggle with keeping versions aligned. Just because we make it available, it does not mean that all consumers will realise this is how it needs to be done though, and some users may still install RR as direct-dependency, and then suffer version-mismatch issues when we update app-shell, or they update RR.

If we do not re-export everything under the RR API, then users could always find a need to install and us RR directly. We won't be able to think of all the ways an AS consumer will want to manipulate routing.

I feel there are some trade-offs either way.

PeerDependencies encourages consumers to use RR — although, if we are assured the versions are aligned then maybe there is nothing too wrong with that. If they start using import from react-router, and what they import is compatible with the RR we use internally, one way of looking at it is that it is fine.

Maybe the lowest friction for now is to re-export all the RR API from App-Shell, so we are not forcing any consumer to install RR to meet their requirements

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants