Draft: proposal - Introduce js-routes gem

Related to #560240 and #579401

The problem

We haven't had clear guidelines about constructing URLs on the frontend and as a result we have a lot of hardcoded URLs on the frontend. These hardcoded URLs cause problems such as:

I recently added some documentation at URLs in GitLab. I am also working on a linter in gitlab-org/frontend/eslint-plugin!147 and there looks to be about 475 violations of URLs that may not work with organization scoped routes.

The current ways we have to expose URLs to the frontend can cause a fair bit of friction:

  1. Passing URLs with data attributes
    • Requires Ruby knowledge and backend review
    • Can cause issues with Backwards compatibility across updates
    • Can not be used for URLs with dynamic parameters (e.g. /*namespace_id/:project_id/-/snippets/new)
  2. GraphQL queries and REST API
    • Can be time consuming to add webPath and other path related fields
    • Some resources need multiple paths such as editPath, newSnippetPath, etc.
    • Can cause issues with Backwards compatibility across updates

How js-routes gem helps

JavaScript path helpers are generated at build time and exposed via ES6 JavaScript exports in the app/assets/javascripts/path_helpers.js file. Behind the scenes it takes care of the relative_url_root setting and organization scoped routes. To generate a URL you import the path helper from ~/path_helpers and call it in your JavaScript of Vue file.

Before

#js-my-app{ data: { new_snippet_path: new_snippet_path } }
import Vue from 'vue';
import MyApp from './my_app.vue'

const initMyApp = () => {
  const el = document.getElementById('js-my-app');

  if (!el) return false;

  const { newSnippetPath } = el.dataset

  return new Vue({
    el,
    render(createElement) {
      return createElement(MyApp, { props: { newSnippetPath } });
    },
  });
}
<script>
import { GlLink } from '@gitlab/ui';

export default {
  components: {
    GlLink
  },
  props: {
    newSnippetPath: {
      type: String,
      required: true,
    }
  }
};
</script>
<template>
  <gl-link :href="newSnippetPath">{{ __('New snippet') }}</gl-link>
</template>

After

<script>
import { GlLink } from '@gitlab/ui';
import { newSnippetPath } from '~/path_helpers';

export default {
  components: {
    GlLink
  },
  methods: {
    newSnippetPath
  }
};
</script>
<template>
  <gl-link :href="newSnippetPath()">{{ __('New snippet') }}</gl-link>
</template>

How does js-routes gem work?

The js-routes gem does most of the heavy lifting but we do some modification in lib/gitlab/middleware/advanced_js_routes.rb. The output looks like this (shortend version)

/**
 * @file Generated by js-routes 2.3.5. Based on Rails 7.2.3 routes of Gitlab::Application.
 * @version cd6518f643220672880a4a37ee9e55331222fc5d25d350abef33c4a720eb6d2d
 * @see https://github.com/railsware/js-routes
 */
// eslint-disable-next-line
const __jsr = (

// Helpers used by the path helpers

// "Private" path helpers

/**
 * Generates rails route to
 * /o/:organization_path/*namespace_id/:project_id/-/snippets/new(.:format)
 * @param {any} organizationPath
 * @param {any} namespaceId
 * @param {any} projectId
 * @param {object | undefined} options
 * @returns {string} route path
 */
const _newOrganizationNamespaceProjectSnippetPath = /*#__PURE__*/ __jsr.r({"organization_path":{"r":true},"namespace_id":{"r":true},"project_id":{"r":true},"format":{}}, [2,[7,"/"],[2,[6,"o"],[2,[7,"/"],[2,[3,"organization_path"],[2,[7,"/"],[2,[5,[3,"namespace_id"]],[2,[7,"/"],[2,[3,"project_id"],[2,[7,"/"],[2,[6,"-"],[2,[7,"/"],[2,[6,"snippets"],[2,[7,"/"],[2,[6,"new"],[1,[2,[8,"."],[3,"format"]]]]]]]]]]]]]]]]]);

/**
 * Generates rails route to
 * /*namespace_id/:project_id/-/snippets/new(.:format)
 * @param {any} namespaceId
 * @param {any} projectId
 * @param {object | undefined} options
 * @returns {string} route path
 */
const _newNamespaceProjectSnippetPath = /*#__PURE__*/ __jsr.r({"namespace_id":{"r":true},"project_id":{"r":true},"format":{}}, [2,[7,"/"],[2,[5,[3,"namespace_id"]],[2,[7,"/"],[2,[3,"project_id"],[2,[7,"/"],[2,[6,"-"],[2,[7,"/"],[2,[6,"snippets"],[2,[7,"/"],[2,[6,"new"],[1,[2,[8,"."],[3,"format"]]]]]]]]]]]]]);

// Check if we are using scoped organization paths
const hasScopedPath = gon?.current_organization?.has_scoped_path ?? false

// "Public" path helpers

/**
 * Generates rails route to
 * /*namespace_id/:project_id/-/snippets/new(.:format)
 * @param {any} namespaceId
 * @param {any} projectId
 * @param {object | undefined} options
 * @returns {string} route path
 */
export const newNamespaceProjectSnippetPath = /*#__PURE__*/ (...args) => {
  if (hasScopedPath) {
    return _newOrganizationNamespaceProjectSnippetPath(gon?.current_organization.path, ...args)
  }

  return _newNamespaceProjectSnippetPath(...args)
}

Organization scoped routes and relative_url_root setting

The JavaScript path helpers take care of these behind the scenes

No org scope Org scope relative_url_root and no org scope relative_url_root and org scope
Screenshot_2025-12-11_at_2.29.59_PM Screenshot_2025-12-11_at_2.31.12_PM Screenshot_2025-12-11_at_2.55.07_PM Screenshot_2025-12-11_at_2.54.21_PM

Typehinting

JSDocs comments and a app/assets/javascripts/path_helpers.d.ts file are generated to provide typehinting for path helpers

Screenshot_2025-12-11_at_12.18.28_PM

Performance

Because of code splitting and ES6 imports bundle size increase is minimal. Only the path helpers that are imported are included in the bundle.

Page specific bundle

app/assets/javascripts/pages/projects/snippets/show/index.js

Before After (using 2 path helpers)
27.71 kb (6.80 kb gzip) 27.82 kb (6.84 kb gzip)

Main bundle

app/assets/javascripts/main.js

Before After (calling configure)
2181.55 kb (627.24 kb gzip) 2194.33 kb (630.44 kb gzip)

Considerations

I think the biggest consideration is the added complexity in lib/gitlab/middleware/advanced_js_routes.rb. If something changes in this file it could break all the routes on the frontend, though we can mitigate this risk with feature specs (and existing feature specs). IMO the benefits outweigh this added complexity but it is worth noting.

Things to figure out still

Shorthand project path helpers

In config/routes.rb#L375 we generate shorthand path helpers for projects. For example instead of needing to do namespace_project_path(project.namespace, project) you can just do project_path(project). The problem is that these shorthand path helpers are not associated with a route and therefore the JavaScript versions are not generated.

On the frontend a lot of the time we don't have access to the project namespace but we do have access to the fullPath which includes the namespace. It would be much easier to be able to do projectPath(project.fullPath) instead of namespaceProjectPath(project.group.path, project.path).

References

How to set up and validate locally

  1. Go to /rails/features and enable the following feature flags:
    • ui_for_organizations
    • organization_switching
    • organization_scoped_paths
  2. Go to a project -> Snippets -> New
  3. Create a snippet
  4. Use the New snippet button in the dropdown in the upper right corner

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Peter Hegman

Merge request reports

Loading