Summary
Inside a .namespace("foo") block, the to= argument of a route gets concatenated with the namespace name in a confusing way: writing the (intuitive but redundant) to="foo/controller##action" produces a lookup for a controller class literally named Foocontroller instead of Controller under the foo package. The correct form is just to="controller##action" — the namespace adds the package prefix automatically — but the failure mode when you get it wrong is opaque.
Repro
// config/routes.cfm — WRONG (matches the namespace prefix redundantly)
mapper()
.namespace("datapai")
.get(name="datapaiDashboard", pattern="dashboard", to="datapai/dashboard##index")
.end()
.end();
Then GET /datapai/dashboard produces:
Wheels.ViewNotFound — Could not find the view page for the `index` action in the `datapai.Datapaidashboard` controller.
Looked for the view at: app/views/datapai/datapaidashboard/index.cfm
The error is misleading. Wheels concatenated the namespace (datapai) with the (already-prefixed) controller path (datapai/dashboard) to form a class name Datapaidashboard — lowercase concatenation with the first letter capitalized. The user, having written datapai/dashboard expecting an app/controllers/datapai/Dashboard.cfc file, can't find the source of the bizarre Datapaidashboard name in the error.
The fix is to drop the redundant prefix:
// config/routes.cfm — CORRECT
mapper()
.namespace("datapai")
.get(name="datapaiDashboard", pattern="dashboard", to="dashboard##index")
.end()
.end();
Now Wheels routes /datapai/dashboard → app/controllers/datapai/Dashboard.cfc#index as expected.
Why this happens
In vendor/wheels/mapper/scoping.cfc:42-49, the scope() function combines the parent scope's path and package with the current scope's, which is correct. But when a route is later registered with to="datapai/dashboard##index", the slash separator in the to= string is interpreted as a controller-path component and concatenated with the namespace's package, yielding datapai/datapai/dashboard → flattened to Datapaidashboard for the class name and datapai/datapaidashboard for the view directory.
There's no obvious place in the codebase where this concatenation is documented or guarded.
Impact
This is a foot-gun for anyone writing namespace routes. The plain to="dashboard##index" form looks underspecified ("but what namespace is this in?") and the redundant to="datapai/dashboard##index" looks defensively correct — until it isn't. The error message gives no hint that the namespace + to= interaction is the cause.
We hit this exactly once during the DataPAI Phase 0 build. Diagnosis required reading both scoping.cfc and the resulting Wheels.ViewNotFound error several times before realizing the issue. The fix is a one-line route change once you know what's wrong, but the diagnosis took ~30 min.
Suggested fixes (any of)
-
Reject the redundant form at route-registration time. Inside a .namespace("foo") scope, if a to= starts with the namespace's name followed by /, throw a routing-config error at startup with a clear message:
Route inside .namespace("foo") should use to="bar##action", not to="foo/bar##action" — the namespace prefix is added automatically. (got: to="foo/bar##action")
-
Strip the redundant prefix automatically and emit a warning. Less strict than (1) but maintains backward compatibility for anyone whose routes currently work despite the redundant form.
-
Document the interaction clearly in guides.wheels.dev/v4-0-0/routing/. The current docs explain .namespace() and to= separately but don't show their interaction or the "no redundant prefix" rule.
-
Improve the ViewNotFound error message to include "Route registered as to='X' inside .namespace('Y')" so the user can trace it back to the route definition rather than the symptom.
Repo / version
- Wheels Core:
4.0.0-SNAPSHOT+1779
- File:
vendor/wheels/mapper/scoping.cfc:42-49 (path/package combine logic)
- Likely also in the route-registration path that parses
to="controller##action" — needs investigation by someone with deeper Wheels routing knowledge to confirm the exact line.
Where found
Diagnosed during the DataPAI Phase 0 build in Titan when adding /datapai/* routes to land internal SSO users on a separate portal. The wrong form was committed first (taken literally from a draft plan that pre-dated familiarity with the Mapper); a follow-up commit on the same branch fixed it. See paiindustries/titan PR #3337 commit ad61a86cd for the corrected routes.cfm.
Summary
Inside a
.namespace("foo")block, theto=argument of a route gets concatenated with the namespace name in a confusing way: writing the (intuitive but redundant)to="foo/controller##action"produces a lookup for a controller class literally namedFoocontrollerinstead ofControllerunder thefoopackage. The correct form is justto="controller##action"— the namespace adds the package prefix automatically — but the failure mode when you get it wrong is opaque.Repro
// config/routes.cfm — WRONG (matches the namespace prefix redundantly) mapper() .namespace("datapai") .get(name="datapaiDashboard", pattern="dashboard", to="datapai/dashboard##index") .end() .end();Then
GET /datapai/dashboardproduces:The error is misleading. Wheels concatenated the namespace (
datapai) with the (already-prefixed) controller path (datapai/dashboard) to form a class nameDatapaidashboard— lowercase concatenation with the first letter capitalized. The user, having writtendatapai/dashboardexpecting anapp/controllers/datapai/Dashboard.cfcfile, can't find the source of the bizarreDatapaidashboardname in the error.The fix is to drop the redundant prefix:
// config/routes.cfm — CORRECT mapper() .namespace("datapai") .get(name="datapaiDashboard", pattern="dashboard", to="dashboard##index") .end() .end();Now Wheels routes
/datapai/dashboard→app/controllers/datapai/Dashboard.cfc#indexas expected.Why this happens
In
vendor/wheels/mapper/scoping.cfc:42-49, thescope()function combines the parent scope'spathandpackagewith the current scope's, which is correct. But when a route is later registered withto="datapai/dashboard##index", the slash separator in theto=string is interpreted as a controller-path component and concatenated with the namespace's package, yieldingdatapai/datapai/dashboard→ flattened toDatapaidashboardfor the class name anddatapai/datapaidashboardfor the view directory.There's no obvious place in the codebase where this concatenation is documented or guarded.
Impact
This is a foot-gun for anyone writing namespace routes. The plain
to="dashboard##index"form looks underspecified ("but what namespace is this in?") and the redundantto="datapai/dashboard##index"looks defensively correct — until it isn't. The error message gives no hint that the namespace +to=interaction is the cause.We hit this exactly once during the DataPAI Phase 0 build. Diagnosis required reading both
scoping.cfcand the resultingWheels.ViewNotFounderror several times before realizing the issue. The fix is a one-line route change once you know what's wrong, but the diagnosis took ~30 min.Suggested fixes (any of)
Reject the redundant form at route-registration time. Inside a
.namespace("foo")scope, if ato=starts with the namespace's name followed by/, throw a routing-config error at startup with a clear message:Strip the redundant prefix automatically and emit a warning. Less strict than (1) but maintains backward compatibility for anyone whose routes currently work despite the redundant form.
Document the interaction clearly in
guides.wheels.dev/v4-0-0/routing/. The current docs explain.namespace()andto=separately but don't show their interaction or the "no redundant prefix" rule.Improve the ViewNotFound error message to include "Route registered as
to='X'inside.namespace('Y')" so the user can trace it back to the route definition rather than the symptom.Repo / version
4.0.0-SNAPSHOT+1779vendor/wheels/mapper/scoping.cfc:42-49(path/package combine logic)to="controller##action"— needs investigation by someone with deeper Wheels routing knowledge to confirm the exact line.Where found
Diagnosed during the DataPAI Phase 0 build in Titan when adding
/datapai/*routes to land internal SSO users on a separate portal. The wrong form was committed first (taken literally from a draft plan that pre-dated familiarity with the Mapper); a follow-up commit on the same branch fixed it. See paiindustries/titan PR #3337 commitad61a86cdfor the corrected routes.cfm.