Skip to content

feat: pass route definition to route components via route prop - #244

Merged
uhyo merged 4 commits into
masterfrom
claude/route-component-object-access-eqhudy
Aug 27, 2026
Merged

feat: pass route definition to route components via route prop#244
uhyo merged 4 commits into
masterfrom
claude/route-component-object-access-eqhudy

Conversation

@uhyo

@uhyo uhyo commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Summary

Route components now receive an opaque handle to their own route definition as the route prop, typed so it can be passed directly to the typed hooks (useRouteParams, useRouteState, useRouteData).

This enables type-safe hook usage when the route definition cannot be imported directly — for example, when route definitions are managed internally by a framework implementing file-system based routing. In such setups, the route prop is the only way for a user-authored component to reach its definition. In ordinary SPA apps where definitions are importable, existing patterns continue to work unchanged.

Changes

  • New RouteHandle<Id, Params, State, Data> type: an opaque handle carrying only the phantom type info and path?. TypefulOpaqueRouteDefinition now extends RouteHandle, so every authored definition is assignable to the handle; its own declared fields (children, exact, requireChildren) are unchanged. Exported from the main entry.
  • RouteComponentProps gains a required route prop typed as RouteHandle<string, TParams, TState, unknown>; RouteComponentPropsWithData narrows it to carry TData. The route()/routeState() overloads and the typed hooks are untouched — hooks accept a RouteHandle structurally.
  • RouteRenderer injects route={match.route} for function components (both loader and non-loader branches). The prop is referentially identical to the registered definition — in the two-phase bindRoute flow, that is the bound (full) definition. JSX-element components are unaffected, as before.
  • Tests: new routeProp.test.tsx covers prop identity for plain, loader, id-less (nearest-context fallback), bindRoute, and nested routes; type-level tests verify the prop's type, that all three typed hooks infer correctly from props.route (including a component authored against RouteComponentPropsOf), that RouteHandle declares only path among the definition fields, and that TypefulOpaqueRouteDefinition keeps its declared fields and extends RouteHandle.
  • Docs: route added to the props listings on the Types API page, a RouteHandle entry on the Types API page and reference index, plus a "The route prop" subsection with an example in the Type Safety guide.

Notes

  • The base interface types the handle's phantom data slot as unknown (not undefined) because interface extension requires the narrowed TData to be assignable to the base's slot.
  • Since the prop is required on RouteComponentProps, code that renders a route component manually with hand-built props (tests, storybook) must now supply a route. Component authors are unaffected — components declaring fewer props remain assignable.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U9kFBcxBJ9ForaLHfSug9E

Route components now receive their own route definition object as the
route prop, typed so it can be passed directly to the typed hooks
(useRouteParams, useRouteState, useRouteData). This enables type-safe
hook usage when the route definition cannot be imported directly, such
as when route definitions are managed internally by a framework
implementing file-system based routing.

Co-Authored-By: Claude <noreply@anthropic.com>

uhyo commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Request: tighten the declared fields of TypefulOpaqueRouteDefinition alongside (or before) this PR.

TypefulOpaqueRouteDefinition currently declares path?, children?, exact?, and requireChildren? as directly readable properties, even though the type is meant to be an opaque, brand-carrying handle (the Id/Params/State/Data info lives in the phantom symbol member and is consumed via the Extract* helpers and typed hooks). Could we drop (or at least reduce) those declared fields?

Why now, in the context of this PR:

  • Before this PR, a user can only hold a TypefulOpaqueRouteDefinition value they authored themselves via route({ id, ... }) / bindRoute() — there is no retrieval API, so reads like def.path are unlikely in the wild (the author already knows the path at the call site).
  • This PR's route prop changes that: every route component will receive one of these values, including code that did not create the definition (the file-system-routing use case this PR targets). Once that ships, users will start reaching for props.route.path etc., and tightening later becomes much more disruptive. This is the cheapest moment to do it.

What tightening would and wouldn't affect:

  • Runtime: nothing. route()/bindRoute() return the definition object as-is; the interface change is purely type-level.
  • Still works: assignability to RouteDefinition/OpaqueRouteDefinition (the removed fields are optional on those targets), <Router routes>, nesting under children, the typed hooks, and the Extract* helpers (they only use the symbol brand via infer). Router internals are typed against InternalRouteDefinition, so they're unaffected too.
  • Breaks (compile-time only): direct reads of def.path/def.children/def.exact/def.requireChildren on values typed as TypefulOpaqueRouteDefinition, and property access through the RouteDefinition union (today someDef.path compiles because all three union members declare it).

Since the type shipped in 1.3.0, removing declared members is strictly a breaking type change and should at minimum get a changelog note (whether it warrants a major is your call given the type is one minor old). A zero-risk middle ground would be keeping path?: string (the one field with a plausible legitimate read, e.g. link building) and dropping only children/exact/requireChildren. Consider giving PartialRouteDefinition the same treatment (it declares path? as well) for consistency.


Generated by Claude Code

Drop the children, exact, and requireChildren declared fields from
TypefulOpaqueRouteDefinition, keeping only path among the definition
fields. The type is meant to be an opaque, brand-carrying handle whose
Id/Params/State/Data info is consumed via the Extract* helpers and
typed hooks. With the new route prop exposing these values to
components that did not author them, this is the cheapest moment to
tighten the surface before direct field reads appear in the wild.
PartialRouteDefinition already declares only path, so it is consistent
as-is.

Runtime behavior is unchanged; the change is purely type-level.
Note: this is a breaking type-level change for code that read
def.children / def.exact / def.requireChildren on values typed as
TypefulOpaqueRouteDefinition, or accessed those properties through the
RouteDefinition union. The in-repo entries.tsx path collectors now use
a local structural view instead.

Co-Authored-By: Claude <noreply@anthropic.com>

uhyo commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Done in cf1e9ff — implemented the middle ground you outlined: TypefulOpaqueRouteDefinition now declares only the phantom symbol member and path?: string; children, exact, and requireChildren are dropped. PartialRouteDefinition already declared only path?, so it needed no change to be consistent.

Details:

  • Runtime: untouched, as expected — the change is purely type-level.
  • Fallout found in-repo: the predicted union break was real. The entries.tsx path collectors in docs, example-rsc, and example-pathless-ssr all read r.children through the RouteDefinition union; each now iterates through a local structural RouteNode view ({ path?; children? }) with a comment explaining why. Nothing else in the monorepo read the removed fields.
  • Guard rails: added type tests asserting that only path remains declared among the definition fields (including that component/loader/action stay undeclared), and that a TypefulOpaqueRouteDefinition remains assignable to RouteDefinition and usable in children.
  • Changelog: the breaking type-level nature is spelled out in the commit body so it surfaces when release notes are generated from the git log. Whether it warrants a major on release is left to you, per your comment.

All packages typecheck, 347 tests pass, lint/format clean.


Generated by Claude Code

@uhyo

uhyo commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Ah, um... the entries.tsx use case is a real use case that we were going to break. My oversight.

Please revert the breaking change. Instead, give the tightened type a new name and use it for the new route prop.

…e route prop

Restore the children, exact, and requireChildren declared fields on
TypefulOpaqueRouteDefinition — reading them (e.g. walking children to
collect static paths in entries.tsx) is a supported use case that the
tightening would have broken.

Instead, introduce a new exported RouteHandle<Id, Params, State, Data>
type: an opaque handle carrying only the phantom type info and path.
TypefulOpaqueRouteDefinition now extends RouteHandle, and the route
prop on RouteComponentProps / RouteComponentPropsWithData is typed as
RouteHandle, so components see the tightened surface while authored
definitions keep their full declared shape. The typed hooks accept
RouteHandle values structurally, unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

uhyo commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Done in c953758 — the tightening is reverted and the tightened shape now lives under a new name used only for the prop.

  • Reverted: TypefulOpaqueRouteDefinition declares path, children, exact, and requireChildren again, and the three entries.tsx files are byte-identical to master (verified with git diff origin/master). No breaking type change remains on existing exports.
  • New type: RouteHandle<Id, Params, State, Data> — the phantom symbol member plus path?: string, nothing else. TypefulOpaqueRouteDefinition now extends RouteHandle, so every authored definition is assignable to the prop, and the typed hooks accept a RouteHandle structurally with no changes to their signatures. Exported from the main entry and documented on the Types API page.
  • Prop typing: RouteComponentProps.route is RouteHandle<string, TParams, TState, unknown>; RouteComponentPropsWithData narrows the data slot to TData.
  • Naming: I picked RouteHandle — short, and "opaque handle" is the standard term for exactly this shape. Happy to rename (e.g. RouteDefinitionHandle) if you prefer something closer to the "route definition" vocabulary; it's a one-symbol change.

Type tests updated: the opacity assertions now target RouteHandle, and new assertions pin that TypefulOpaqueRouteDefinition keeps its declared fields and extends RouteHandle. All packages typecheck, 348 tests pass, lint/format clean.


Generated by Claude Code

The typed hooks (useRouteParams, useRouteState, useRouteData) already
accepted RouteHandle values, but only structurally: the constraint arm
was TypefulOpaqueRouteDefinition, which a handle satisfies solely
because Typeful's extra declared fields are all optional. Rewrite the
hook constraints and the Extract* helpers' second branch in terms of
RouteHandle so acceptance is guaranteed by the extends relationship
rather than by structural coincidence. Every existing call site is
unaffected since TypefulOpaqueRouteDefinition extends RouteHandle.

Also add type tests covering RouteHandle-typed values against the
hooks and Extract* helpers, and mention RouteHandle in the type-safe
hooks docs.

Co-Authored-By: Claude <noreply@anthropic.com>
@uhyo
uhyo merged commit 897c295 into master Aug 27, 2026
1 check passed
@uhyo
uhyo deleted the claude/route-component-object-access-eqhudy branch August 27, 2026 13:16
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