Skip to content

MicroRouter._createContext() doesn't strip URL hash from pathname → 404 on every <a href="/route#anchor"> #128

@bricous

Description

@bricous

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions