Skip to content

fix: support arbitrary nesting depth in nestedPath helper - #698

Open
SaqlainR55 wants to merge 1 commit into
apptension:masterfrom
SaqlainR55:fix/nested-path-helper-deep-nesting
Open

fix: support arbitrary nesting depth in nestedPath helper#698
SaqlainR55 wants to merge 1 commit into
apptension:masterfrom
SaqlainR55:fix/nested-path-helper-deep-nesting

Conversation

@SaqlainR55

Copy link
Copy Markdown

Please check if the PR fulfills these requirements

  • The commit message follows our guidelines
  • Tests for the changes have been added (for bug fixes/features)
  • Docs have been added / updated (for bug fixes / features)

What kind of change does this PR introduce?

Bug fix + small feature addition. The PR addresses three related issues in the nestedPath helper at packages/webapp-libs/webapp-core/src/utils/path.ts, all surfaced when using the helper with route configurations nested 3+ levels deep.

What is the current behavior?

1. mapRoot does not recurse past 2 levels of nesting.

The helper claims to prefix nested route configs with the root, but the implementation of mapRoot only iterates string-valued keys at the current level. When it encounters a child object (the result of an inner nestedPath call), it spreads the object through unchanged instead of recursing into it. As a result, the outermost prefix is silently dropped for any route nested 3 or more levels deep.

Reproduction:

const result = nestedPath('a', {
  mid: nestedPath('b', {
    leaf: nestedPath('c', { x: '' }),
  }),
});
// Expected: result.mid.leaf.x === 'a/b/c/'
// Actual:   result.mid.leaf.x === 'b/c/'   (the 'a/' prefix is missing)

2. No helper for parent route mounts.

When mounting a nested route group as a parent <Route> inside a child <Routes> tree, React Router requires a path of the form 'segment/*' — the local segment with a wildcard suffix, no parent prefix (since React Router composes parent + child paths automatically).

The helper exposes no method that returns this. getRelativeUrl(key) returns the original input (the nested object for nested keys, or a leaf string), paths.index returns the absolute path with the parent prefix already baked in. Neither is suitable for a parent mount.

Consumers work around this by hardcoding strings like <Route path="customers/*" element={<Customers />} />. These hardcoded strings drift when the config is later renamed.

3. getRelativeUrl returns a union of all value types.

The current signature (route: keyof T) => nestedRoutes[route] collapses to return type T[keyof T] — the union of every value type in the config. For configs with mixed-value children (some string leaves, some nested objects), TypeScript widens to string | NestedObject. Calling sites that pass the result to APIs expecting string (e.g. React Router's <Route path>) reject the value and require a cast.

What is the new behavior?

1. mapRoot now recurses. An else if branch handles non-null, non-array object values by calling mapRoot(root, value) on them. The recursion is depth-agnostic; the same example above now produces 'a/b/c/' correctly. The value !== null && !Array.isArray(value) guard is defensive — null and arrays both report as 'object' from typeof and would corrupt the result if recursed into.

2. New getMountPath() method. Returns ${root}/* for the current nestedPath invocation — the local segment plus wildcard, suitable for parent route mounts. Consumers can now write:

<Routes>
  <Route path={RoutesConfig.example.getMountPath()} element={<Example />} />
  <Route path={RoutesConfig.crudDemoItem.getRelativeUrl('details')} element={<Details />} />
</Routes>

The JSDoc has been extended with an @example block documenting when to use getMountPath() (parent mount of a nested route group) versus getRelativeUrl(key) (leaf route mapping directly to a component).

3. getRelativeUrl is generic over the key. Signature changes from (route: keyof T) => nestedRoutes[route] to <K extends keyof T>(route: K): T[K] => nestedRoutes[route]. TypeScript now narrows the return type per call: getRelativeUrl('cart') returns string when cart is a string in the config, and the nested object type when the key points to a nested config. No more union; no more casts at the call site.

Does this PR introduce a breaking change?

No.

  • The mapRoot recursion fix only changes behavior for configs nested 3+ levels deep that were previously broken — no test or consumer in the repository depends on the broken behavior. The two existing 2-level tests continue to pass unchanged.
  • getMountPath() is a new method; no existing call site is affected.
  • The getRelativeUrl type change is a strict improvement — every previously valid call resolves to a return type that's a subtype of the old union, so existing code continues to type-check (and additionally compiles in places that previously required casts).

Other information

Tests added (in path.spec.ts):

  • 3-level prefix propagation
  • 4-level prefix propagation (depth-agnostic guard)
  • Helper preservation (getRelativeUrl, getLocalePath) on deeply nested objects
  • getMountPath() at simple case
  • getMountPath() at multiple depths
  • Mixed getMountPath() + getRelativeUrl() usage matching the JSDoc example

The two existing toEqual shape assertions are updated to include getMountPath: expect.any(Function) since the helper now returns this method on every nested config object.

Discovery context: All three issues surfaced together while migrating a downstream app to a route configuration nested 3 levels deep (admin → payments → customers → leaves). Each issue blocked the migration on its own: the recursion bug produced wrong URLs at runtime, the missing mount-path helper meant we had to choose between hardcoded literals or invalid casts, and the loose getRelativeUrl typing made even the workaround fail TypeScript compilation. Bundling the three fixes here since they're tightly related and reviewing them together gives full context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant