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:
-
relative_url_rootsetting bugs - Geo bugs
- In the future when we use organization scoped routes some of these hardcoded URls on the frontend won't work correctly
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:
-
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)
-
GraphQL queries and REST API
- Can be time consuming to add
webPathand other path related fields - Some resources need multiple paths such as
editPath,newSnippetPath, etc. - Can cause issues with Backwards compatibility across updates
- Can be time consuming to add
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 |
|---|---|---|---|
|
|
|
|
Typehinting
JSDocs comments and a app/assets/javascripts/path_helpers.d.ts file are generated to provide typehinting for path helpers
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
- Go to
/rails/featuresand enable the following feature flags:ui_for_organizationsorganization_switchingorganization_scoped_paths
- Go to a project -> Snippets -> New
- Create a snippet
- Use the
New snippetbutton 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.




