Summary
Since the migration from page.js (≤ 3.13.x) to the in-house MicroRouter
(3.14.0), any link of the form <a href="/route#anchor"> produces a 404,
because the URL fragment is kept inside pathname during route matching.
pathFor with hash= is also broken since it concatenates #anchor to the
path it generates.
Reproduction
<a href="/profile#security">Open security tab</a>
FlowRouter.route('/profile', { name: 'profile', action() { /* ... */ } });
Click on the link. Expected: route profile matches, browser scrolls to
#security. Actual: no route matches, app falls through to its 404 page.
Root cause
In lib/micro-router.js, _createContext(fullPath) splits fullPath on
? to separate the query string but never splits on #. The hash therefore
remains glued to pathname:
_createContext(fullPath) {
const [pathPart, ...qsParts] = fullPath.split('?');
const path = pathPart || '/';
// ...
return {
path: fullPath,
pathname: path, // <-- still contains "#anchor"
querystring: qsParts.join('?'),
params: {},
state: null,
};
}
_dispatch(ctx) then runs matchPath(route.compiled, ctx.pathname) against
each route's compiled regex (anchored on $), so /route never matches
/route#anchor and no handler fires.
page.js (the previous internal router) parsed the hash separately, which
is why the regression only shows up after upgrading to 3.14.0.
The same problem affects pathFor (in _init.js, around L332):
return FlowRouter.path(path, view.hash, query) + (hashBang ? '#' + hashBang : '');
The generated href is correct, but feeding it back into the router triggers
the same _createContext bug.
Suggested fix
Strip the hash before building pathname, keep it on the context for
optional consumers (e.g. anchor scroll):
_createContext(fullPath) {
// Separate hash first
const hashIdx = fullPath.indexOf('#');
const beforeHash = hashIdx >= 0 ? fullPath.slice(0, hashIdx) : fullPath;
const hash = hashIdx >= 0 ? fullPath.slice(hashIdx + 1) : '';
const [pathPart, ...qsParts] = beforeHash.split('?');
const path = pathPart || '/';
return {
path: fullPath, // unchanged — pushState keeps the hash
pathname: path, // hash stripped → matching works again
querystring: qsParts.join('?'),
hash, // exposed for consumers
params: {},
state: null,
};
}
This preserves ctx.path (so _pushState still writes the full URL
including the hash to the address bar) while letting the matcher see a
clean pathname.
Note: with pushState, browsers do not auto-scroll to the anchor, so
applications still need a way to trigger that themselves. Exposing hash
on the context (or firing a dedicated event) makes this straightforward.
Workaround for affected apps
Until a fix is released, monkey-patch the instance at startup:
const microRouter = FlowRouter._microRouter;
if (microRouter) {
const original = microRouter._createContext.bind(microRouter);
microRouter._createContext = (fullPath) => {
const ctx = original(fullPath);
const i = ctx.pathname.indexOf('#');
if (i >= 0) {
ctx.hash = ctx.pathname.slice(i + 1);
ctx.pathname = ctx.pathname.slice(0, i);
}
return ctx;
};
}
Versions
- meteor/ostrio:flow-router-extra : 3.14.0
- meteor : 3.4.1
- Browser : Chromium-based and Firefox (independent of browser, the bug is
in the JS routing logic).
This report is redacted with the help of Claude code
Summary
Since the migration from
page.js(≤ 3.13.x) to the in-houseMicroRouter(3.14.0), any link of the form
<a href="/route#anchor">produces a 404,because the URL fragment is kept inside
pathnameduring route matching.pathForwithhash=is also broken since it concatenates#anchorto thepath it generates.
Reproduction
Click on the link. Expected: route profile matches, browser scrolls to
#security. Actual: no route matches, app falls through to its 404 page.
Root cause
In lib/micro-router.js, _createContext(fullPath) splits fullPath on
? to separate the query string but never splits on #. The hash therefore
remains glued to pathname:
_dispatch(ctx) then runs matchPath(route.compiled, ctx.pathname) against
each route's compiled regex (anchored on $), so /route never matches
/route#anchor and no handler fires.
page.js (the previous internal router) parsed the hash separately, which
is why the regression only shows up after upgrading to 3.14.0.
The same problem affects pathFor (in _init.js, around L332):
return FlowRouter.path(path, view.hash, query) + (hashBang ? '#' + hashBang : '');
The generated href is correct, but feeding it back into the router triggers
the same _createContext bug.
Suggested fix
Strip the hash before building pathname, keep it on the context for
optional consumers (e.g. anchor scroll):
This preserves ctx.path (so _pushState still writes the full URL
including the hash to the address bar) while letting the matcher see a
clean pathname.
Note: with pushState, browsers do not auto-scroll to the anchor, so
applications still need a way to trigger that themselves. Exposing hash
on the context (or firing a dedicated event) makes this straightforward.
Workaround for affected apps
Until a fix is released, monkey-patch the instance at startup:
Versions
in the JS routing logic).
This report is redacted with the help of Claude code