Skip to content

feat: routing — $location + ngRoute ($route, $routeParams, ngView) (spec 040) - #46

Merged
Mgrdich merged 13 commits into
masterfrom
routes
Jul 8, 2026
Merged

Mgrdich merged 13 commits into
masterfrom
routes

Conversation

@Mgrdich

@Mgrdich Mgrdich commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the Phase 4 Routing roadmap item via the AWOS spec-040 triad (functional spec → technical considerations → 8 vertical slices). Two new subpaths:

$location / $locationProvider — new core ng service (src/location/, ./location)

  • Full AngularJS-parity surface: path/search/hash/url/absUrl/protocol/host/port/state/replace(), fluent setters
  • Hashbang mode by default (#! prefix, configurable via hashPrefix), HTML5 mode opt-in (html5Mode) writing through pushState/replaceState
  • Digest-synced: a $rootScope watch flushes app-driven changes and broadcasts cancelable $locationChangeStart → browser write → $locationChangeSuccess; browser hashchange/popstate arrive through injectable seams with guarded dispatch ('eventListener' cause; canceled browser-driven changes force-replace the old URL back)
  • Pure factory behind injectable browser seams (locationRef/historyRef/addEventListenerRef) — fully unit-testable without touching jsdom navigation

ngRoute — new opt-in module (src/route/, ./route)

  • $routeProvider.when()/otherwise() with upstream-ported pattern compiler (:param, :param?, :rest*, per-route caseInsensitiveMatch, trailing-slash tolerance both directions)
  • Navigation pipeline: first-match-wins → cancelable $routeChangeStart → sync fast path (inline template, no resolve) or async $q path (templateUrl via $templateRequest + resolve map via $injector under a single object-form $q.all) with a staleness token → commit ($route.current, in-place $routeParams) → $routeChangeSuccess; any failure → $routeChangeError, previous view intact
  • redirectTo (string interpolation + fn forms, .replace() history semantics) with a 10-hop redirect-loop guard (RouteRedirectionLoopError — hardening beyond upstream), reloadOnSearch/reloadOnUrl → $routeUpdate (no teardown), $route.reload(), $route.updateParams()
  • ngView directive (ng-include-pattern: comment placeholder, wrapper div, dual cleanup) rendering locals.$template, instantiating the per-route controller (controllerAs + 'Ctrl as vm' forms) with resolve locals injectable by name, emitting $viewContentLoaded
  • EXCEPTION_HANDLER_CAUSES stays 13 — no new cause token

Notable

  • Parity suites ported from upstream routeSpec/routeParamsSpec/ngViewSpec/locationSpec found and fixed 3 real bugs (throwing redirectTo fn not broadcasting $routeChangeError; matchRoute double-decode URIError; ngView link-before-insert breaking require: '^' across the view boundary)
  • Documented divergences pinned in READMEs/TSDoc (no $browser, standard URL encoding, redirect-pass event silence, wrapper-div insertion, sync fast path, no scope.$resolve/eagerInstantiation)
  • Docs: src/location/README.md, src/route/README.md, context/diagrams/routing.md, CLAUDE.md module rows + invariants, roadmap ticked

Test plan

  • pnpm typecheck / pnpm lint clean
  • pnpm test — 225 files, 4664 passed / 26 skipped (~430 new routing/location tests: pure-helper units, fake-seam factories, DI integration, jsdom ngView rendering, async templateUrl/resolve flows, redirect/reload semantics, upstream-ported parity scenarios)
  • pnpm build — ./location + ./route ESM/CJS/.d.ts triples emitted
  • Coverage: src/location 98.9% / src/route 96.0% lines (≥90% gate)
  • Spec 040 verified — all 45 acceptance criteria met, status Completed

🤖 Generated with Claude Code

Mgrdich and others added 13 commits July 6, 2026 14:48
… tasks (spec 040)

AWOS spec-driven planning for the Phase 4 Routing feature (ngRoute):

- functional-spec.md — 14 requirements covering opt-in routing, route
  declaration + fallback, parameterized patterns, developer-selectable
  hash vs clean URLs, ng-view, inline + remote templates, per-route
  controllers, redirects, resolve pre-loading, navigation lifecycle
  events, current URL params/route info, manual reload. Full AngularJS
  ngRoute parity; ui-router / animations / angular namespace out of scope.
- technical-considerations.md — new opt-in ngRoute module (src/route/)
  plus a genuinely-new $location service (src/location/, on core
  ngModule, full parity, behind injectable browser seams). ngView reuses
  ng-include mechanics; resolve via $injector.invoke + $q.all; no new
  EXCEPTION_HANDLER_CAUSES token.
- tasks.md — 8 vertical slices, each green-at-completion, delegated
  across rollup-build / typescript-framework / vitest-testing /
  typedoc-docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…registration (spec 040 slice 1)

- src/location/ new subpath: location-url.ts (pure hashbang parse/compose),
  location.ts (createLocation pure factory behind locationRef/historyRef
  seams), location-types.ts (LocationService contract), location-provider.ts
  ($LocationProvider with hashPrefix getter/setter frozen at $get), barrel.
- Registered .provider('$location', $LocationProvider) on core ngModule;
  ng registry widened with $location, config registry with $locationProvider.
- Packaging: @location/* alias (tsconfig, vitest), rollup entry, ./location
  package.json exports triple.
- 127 new tests (pure helpers, fake-seam factory, DI integration);
  src/location at 100% line coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tate, replace(), $locationChange* events (spec 040 slice 2)

- absUrl/protocol/host/port (per-scheme default ports) + state() (HTML5-only
  setter, exported STATE_REQUIRES_HTML5_MODE_MESSAGE throw in hashbang) +
  fluent replace() with auto-clearing history-REPLACE flag.
- HTML5 mode: base-prefix parse/compose (parseServerUrl/normalizeBasePath/
  parseHtml5Url/composeHtml5Url), pushState/replaceState writes with state,
  foreign-URL → empty app URL.
- $LocationProvider.html5Mode() getter/setter (boolean | {enabled,
  requireBase, rewriteLinks} — only enabled honored, documented), frozen
  at $get alongside hashPrefix.
- Digest sync: $get installs a $rootScope side-effecting watch comparing
  working vs committed absUrl/state — broadcasts cancelable
  $locationChangeStart then browser write then $locationChangeSuccess;
  initial fire newUrl === oldUrl with no write. Browser hashchange/popstate
  via injectable addEventListenerRef seam, dispatched through a guarded
  local dispatchGuarded → invokeExceptionHandler('eventListener').
  Canceled app-driven change reverts state; canceled browser-driven change
  force-replaces the old URL back. EXCEPTION_HANDLER_CAUSES stays 13.
- Standalone (non-DI) createLocation keeps synchronous writes; 3 DI tests
  adjusted to digest-scheduled semantics.
- 93 new tests (location-html5.test.ts, location-events.test.ts) — full
  suite 4463 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ange events (spec 040 slice 3)

- src/route/ new opt-in module: route-path.ts (upstream-ported pathRegExp
  — :param / :param? slash-folding / :rest* greedy, ^…/?$ trailing-slash
  tolerance, caseInsensitiveMatch; internal), route-types.ts (RouteDefinition
  with custom-field index signature, Route, RouteService, RouteParams),
  route-provider.ts ($RouteProvider.when chainable + otherwise with string
  → {redirectTo} shorthand), route.ts (createRoute — $locationChangeSuccess
  listener, first-match-wins pipeline, cancelable $routeChangeStart →
  current + in-place $routeParams → $routeChangeSuccess, upstream no-match
  shapes, reload() via forceReload + $evalAsync), ng-route-module.ts
  (createModule('ngRoute', []) + ModuleRegistry merge), barrel + root
  re-exports (provider DI-only at root — sanitize precedent).
- Params merge: path params win over search (upstream extend order).
- Packaging: @route/* alias (tsconfig, vitest), rollup entry, ./route
  exports triple.
- 79 new tests (route-path units, DI-driven $route, module wiring);
  full suite 4542 passing.
- Known one-way trailing-slash gap for patterns AUTHORED with a trailing
  slash (upstream covers via companion redirect) — addressed in slice 7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ler (spec 040 slice 4)

- src/route/ng-view.ts: ngInclude-clone DDO (restrict ECA, priority 400,
  terminal, transclude: 'element') keyed off $route.current +
  $routeChangeSuccess. Render order pinned to upstream ngViewFillContent:
  teardown → parseTemplate into wrapper <div> → child scope → $compile →
  controller instantiate ($controller 3-arg form; 'Ctrl as vm' suffix
  supported; stashed via stashController under 'ngController') → link →
  insert after Comment placeholder → $viewContentLoaded $emit on the new
  scope.
- Dual load-bearing cleanup (addElementCleanup on the placeholder +
  scope $destroy listener); render errors route via
  $exceptionHandler('$compile') — tuple stays 13.
- template string used verbatim (empty string still renders + runs
  controller); fn form called with current.params; templateUrl-only
  silently renders nothing (slice 5).
- Registered via .directive('ngView', …) module-DSL sugar on ngRoute
  (DI-only, typed registry widened automatically).
- 21 new jsdom integration tests; full suite 4563 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…orm locals.$template (spec 040 slice 5)

- route.ts pipeline: after the cancelable $routeChangeStart, templateUrl
  routes (string + fn-of-params forms) fetch via q.when($templateRequest(url))
  → commit (current + $routeParams + $routeChangeSuccess) on resolve,
  $routeChangeError(next, previous, rejection) with NO commit on reject
  (previous view intact; error event is the channel — not $exceptionHandler).
- Sync fast path preserved (documented divergence): inline template / no
  template commits same-digest — slice 3/4 contracts byte-unchanged. Inline
  wins over templateUrl (upstream getTemplateFor precedence).
- Staleness token drops a superseded in-flight fetch silently (no events,
  no DOM); vetoed Start does not bump the token.
- Uniform locals contract: committed routes always carry a locals object;
  locals.$template present whenever template text resolved (inline or
  fetched) — the upstream slot slice 6 merges resolve entries into.
  ngView renders locals.$template. route-template.ts pure helpers extracted.
- $get deps widened (+$q, +$templateRequest); cache reuse free via the
  cache-first $templateRequest.
- 9 new async tests (real createTemplateRequest cache-reuse, rejection,
  staleness race, fn forms) + stale slice-4 description reworded;
  full suite 4573 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… $q.all, $routeChangeError (spec 040 slice 6)

- route.ts: resolve map processed after the cancelable $routeChangeStart —
  string entries via injector.get, invokables via injector.invoke (no
  route-param locals, upstream parity; sync throws caught per-entry and
  funneled as q.reject). Single object-form q.all({...entries, $template})
  ($template assigned last, wins collisions) — resolved object becomes
  next.locals.
- Fast path narrowed to "no templateUrl AND no resolve"; either takes the
  async arm under the existing staleness token (stale success AND failure
  dropped silently).
- Any failure (reject, sync throw, template fetch) → $routeChangeError
  (next, previous, rejection), NO commit, previous view stays mounted;
  rejection kept handled — zero unhandled-$q reports.
- ng-view.ts: controller locals { ...current.locals, $scope: newScope }
  ($scope wins) — resolve results injectable by name (R9). Upstream
  scope.$resolve publication deliberately not shipped.
- $get widened with $injector (6 deps).
- 11 new tests (success by-name injection, pending state, mixed
  plain/$q/native aggregation, exact-rejection Error event, sync-throw
  funneling, templateUrl+resolve compose, $template collision, stale
  success/failure races, $scope precedence); full suite 4584 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oop guard (spec 040 slice 7)

- redirectTo: string form via the upstream interpolate() port
  (route-redirect.ts — ':'-split, consumed keys deleted so .search carries
  leftovers) → $location.path(...).search(params).replace(); fn form called
  (pathParams, path, search) → $location.url(result).replace(); undefined
  return → no redirect. Redirect pass emits NO route events and never
  touches current (documented divergence — target's pass fires Start/
  Success); same-URL self-redirect escape commits normally.
- RouteRedirectionLoopError (route-error.ts, brand-named) + exported
  REDIRECT_LOOP_THRESHOLD (10): consecutive-redirect counter → one
  $routeChangeError broadcast, $location not written, digest settles.
  No new EXCEPTION_HANDLER_CAUSES token (stays 13).
- $routeUpdate branch (upstream boolean byte-equivalent): !forceReload &&
  same $$route && (reloadOnUrl === false || (reloadOnSearch === false &&
  isEqual(pathParams))) → params + $routeParams updated in place,
  $broadcast('$routeUpdate', current), NO teardown (pathParams stale —
  upstream parity). reload() bypasses via forceReload → fresh resolve +
  fresh controller.
- updateParams(newParams): merged params → interpolated originalPath +
  leftover search, normal history entry, pipeline honors reloadOnSearch;
  no current route → synchronous Error.
- route-path.ts: authored trailing slash stripped pre-compile — '/list/'
  now matches /list AND /list/ (closes the slice-3 one-way gap).
- One sanctioned test update (otherwise-shorthand now redirects); 17 new
  tests; full suite 4601 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…agram, roadmap (spec 040 slice 8)

- Parity suites: route-parity.test.ts (41) + location-parity.test.ts (16)
  porting upstream routeSpec/routeParamsSpec/ngViewSpec/locationSpec
  scenario intent (chained-event ordering, incomplete-param fall-through,
  optional-param matrices, redirect no-process-bits, mid-segment params,
  ngView-in-ngInclude, Start-redirect semantics, no-infinite-digest
  guards); documented divergences pinned with naming comments.
- Three parity bugs found by the suite and FIXED:
  1. throwing redirectTo fn now broadcasts $routeChangeError (was escaping
     to the digest watch-error path),
  2. matchRoute no longer double-decodes captures (bare '%' path values
     match instead of silent URIError no-match; upstream never decodes),
  3. ngView inserts the container BEFORE linking (require: '^…' from route
     templates now resolves across the view boundary — the spec-032
     cloneAttachFn attach-before-link precedent), link-throw cleanup
     removes the partially-mounted wrapper.
- Docs: src/location/README.md + src/route/README.md (contracts, flush
  recipes, documented divergences), context/diagrams/routing.md + index
  row + EXPECTED_DIAGRAMS pin, CLAUDE.md module rows + six invariants +
  "Where to look when…" entries, roadmap Routing items ticked
  (spec 040 — shipped).
- Final gates: typecheck / lint / test (225 files, 4664 passed, 26
  skipped) / build (./location + ./route dist triples emitted) green;
  coverage src/location 98.9% / src/route 96.0% lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- All 45 acceptance criteria verified against the implementation + the
  ~430 routing/location tests; ticked.
- R14 wording corrected to the approved AngularJS-parity behavior
  (default rebuilds on query-only change; reloadOnSearch/reloadOnUrl
  false opt out into $routeUpdate) — the original draft inverted the
  default; implementation was already upstream-correct.
- functional-spec.md + technical-considerations.md Status → Completed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sses (spec 040)

The $routeUpdate update-only branch and the no-match/no-previous silent
bail returned from update() before the navigation-token bump, so an
in-flight async navigation (templateUrl fetch / resolve) started by an
earlier pass could still commit AFTER the URL had moved on — $route.current
ended up on a route the browser URL no longer pointed at (repro: commit /x
with reloadOnSearch:false, start an async nav to /a, change /x's query
before the fetch settles → the stale /a commit landed under the /x URL).

Both early-return branches now supersede the in-flight pass, matching the
treatment runRedirect already applied; upstream avoids the hole by
reassigning $route.current eagerly, which its staleness check reads — the
token bump is this project's equivalent. Two regression tests pin the drop;
route.ts file-header + CLAUDE.md staleness wording updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t note (spec 040 review follow-ups)

createLocation always supported a basePath seam, but $LocationProvider.$get
never threaded it, so an HTML5-mode app served under a subpath had no DI
path to configure the base and every browser URL parsed against '/'. Adds
the spec-034-idiom basePath() getter/setter (default '/', TypeError on
non-string, frozen at $get) as the explicit stand-in for upstream's
<base href> detection, with provider-surface + end-to-end DI tests
(subpath parse, composed pushState writes, hashbang no-op) and README /
CLAUDE.md documentation of the divergence.

Also records src/route/route.ts (586 lines, ~40% file-header TSDoc) in
CLAUDE.md's over-500-line audit list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uards (spec 040 review follow-up)

route.ts's getResolveMap asserted the resolve map's value type
(`as Record<string, string | Invokable>`) and ng-view.ts asserted the
@di-Invokable → ControllerInvokable bridge on the route `controller`
field — both narrowing consumer-authored values admitted by the
RouteDefinition index signature.

getResolveMap now narrows via @core's isObject and keeps values
`unknown`; the resolve loop discriminates each entry with a new
file-local isResolveInvokable guard (function-or-array — mirroring
annotate's own top-level triage, full validation stays with
$injector.invoke) and rejects any other shape with a TypeError through
the same q.reject → $routeChangeError channel. ngView discriminates the
controller field with a matching isControllerInvokable guard; garbage
throws a TypeError into the existing render try/catch → '$compile'
route. Two tests pin the new garbage paths; every previously-valid
shape reaches the injector/$controller unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Mgrdich
Mgrdich merged commit e132c0b into master Jul 8, 2026
2 checks passed
@Mgrdich
Mgrdich deleted the routes branch July 8, 2026 13:51
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