From 9ec429b67bbc4b8953457fd48da4fc0bf804614f Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Mon, 6 Jul 2026 14:48:50 -0400 Subject: [PATCH 01/13] =?UTF-8?q?docs:=20routing=20spec=20triad=20?= =?UTF-8?q?=E2=80=94=20functional-spec,=20technical-considerations,=20task?= =?UTF-8?q?s=20(spec=20040)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- context/spec/040-routing/functional-spec.md | 171 ++++++++++++++++++ context/spec/040-routing/tasks.md | 57 ++++++ .../040-routing/technical-considerations.md | 102 +++++++++++ 3 files changed, 330 insertions(+) create mode 100644 context/spec/040-routing/functional-spec.md create mode 100644 context/spec/040-routing/tasks.md create mode 100644 context/spec/040-routing/technical-considerations.md diff --git a/context/spec/040-routing/functional-spec.md b/context/spec/040-routing/functional-spec.md new file mode 100644 index 0000000..b5f75d8 --- /dev/null +++ b/context/spec/040-routing/functional-spec.md @@ -0,0 +1,171 @@ +# Functional Specification: Routing + +- **Roadmap Item:** Routing — route configuration (`$routeProvider`), view rendering (`ng-view`), and route lifecycle (`resolve`, navigation events, `$routeParams`) — Phase 4 +- **Status:** Draft +- **Author:** AWOS / mminasian + +--- + +## 1. Overview and Rationale (The "Why") + +The framework can already build and bind a single screen — compile templates, run controllers and forms, and fetch data over the network. What it **cannot** do yet is let a developer build a **multi-screen single-page application**: one where the browser URL selects which screen is shown, and moving between screens swaps the visible content **without a full page reload**. + +**The problem today:** A developer building anything beyond one screen has to hand-wire URL watching, screen swapping, and back/forward-button handling themselves. There is no supported way to say "when the URL is `/users/42`, show the user-detail screen for user 42." + +**The desired outcome:** A developer opts routing into their app, declares a set of routes (URL pattern → screen), and drops a single placeholder into their page. From then on: + +- Visiting a URL shows the matching screen. +- Clicking an in-app link or pressing the browser back/forward buttons swaps screens instantly, with no page reload. +- Values embedded in the URL (like the `42` in `/users/42`) are handed to the screen. +- A screen can pre-load its data so it never appears half-empty. + +**Success is measured by** behavioral parity with AngularJS `ngRoute`: the capabilities below behave as a developer migrating from AngularJS would expect, validated by test scenarios ported from the original AngularJS routing test suite. + +--- + +## 2. Functional Requirements (The "What") + +> Throughout, "the developer" is the person building an app with this framework; "the visitor" is the end user of that app in a browser. + +### R1 — Routing is an opt-in capability + +- **As a** developer, **I want to** explicitly add routing to my app, **so that** apps that never navigate between screens don't carry routing they don't use (mirroring how sanitization is already opt-in here). + - **Acceptance Criteria:** + - [ ] An app that does not opt into routing behaves exactly as it does today — no routing behavior appears, nothing breaks. + - [ ] After the developer opts routing into their app's dependency list, route configuration, the view placeholder, and the lifecycle signals below all become available. + +### R2 — Declaring routes + +- **As a** developer, **I want to** map URL patterns to screens and set a fallback, **so that** each URL shows the right screen. + - **Acceptance Criteria:** + - [ ] The developer can register a route by pairing a URL pattern with a screen definition. + - [ ] The developer can register a fallback that is used whenever the current URL matches none of the registered routes. + - [ ] When the visitor is at a URL that matches a registered route, that route's screen is shown. + - [ ] When the visitor is at a URL that matches no route and a fallback is defined, the fallback screen is shown. + - [ ] When the visitor is at a URL that matches no route and **no** fallback is defined, the view placeholder is left empty (no error, no crash). + +### R3 — URL patterns with parameters + +- **As a** developer, **I want** URL patterns to capture pieces of the URL, **so that** one route can serve many addresses (e.g. every user's detail page). + - **Acceptance Criteria:** + - [ ] A pattern can contain named placeholders (e.g. `/users/:id`), and the value in that position (`42` for `/users/42`) is captured and made available to the screen. + - [ ] A pattern can contain an optional placeholder that still matches when that segment is absent. + - [ ] A pattern can contain a "capture the rest of the path" placeholder that matches multiple segments. + - [ ] Values after `?` in the URL (query values) are captured and made available to the screen alongside path values. + - [ ] A route can opt into matching that ignores letter case (so `/Users/42` matches a `/users/:id` pattern). + - [ ] A difference of only a trailing slash between the URL and the pattern does not prevent a match. + +### R4 — Developer-selectable URL style + +- **As a** developer, **I want to** choose how routed URLs appear in the address bar, **so that** I can start with zero server setup and later switch to clean URLs. + - **Acceptance Criteria:** + - [ ] By default, routed URLs use the hash style (e.g. `example.com/#/users/42`), which works on any static host with no server configuration. + - [ ] The developer can switch on the clean-URL style (e.g. `example.com/users/42`). + - [ ] Under either style, visiting a routed URL directly, refreshing, and using back/forward all show the correct screen. _(Clean-URL style additionally assumes the host serves the app for unknown paths — this is a documented deployment requirement, not something the framework can enforce.)_ + +### R5 — The view placeholder (`ng-view`) + +- **As a** developer, **I want** a single placeholder in my page that shows the current screen, **so that** I don't manually insert and remove screens. + - **Acceptance Criteria:** + - [ ] The developer marks one spot in their page as the view placeholder. + - [ ] When a route becomes active, its screen is rendered into that placeholder. + - [ ] When navigation moves to a different route, the previous screen is fully removed (its behavior and bindings stop) and the new screen replaces it. + - [ ] The rendered screen behaves like any other part of the app — its bindings update, its controller runs, nested directives work. + +### R6 — Route screens: inline and remote templates + +- **As a** developer, **I want** a route's screen to come from either an inline snippet or a separate template file, **so that** I can keep small screens inline and large ones in their own files. + - **Acceptance Criteria:** + - [ ] A route can supply its screen as an inline template. + - [ ] A route can supply its screen as a template loaded from a URL; the screen appears once it has loaded. + - [ ] A remote template that has already been loaded once is reused without re-fetching. + +### R7 — Per-route controller + +- **As a** developer, **I want** a route to run a controller when its screen loads, **so that** the screen has its own logic and data. + - **Acceptance Criteria:** + - [ ] A route can name a controller that runs when its screen is shown. + - [ ] The developer can give that controller a friendly alias for use inside the screen's template. + - [ ] The controller is created fresh each time its route becomes active and is discarded when the visitor navigates away. + +### R8 — Redirects + +- **As a** developer, **I want** some URLs to send the visitor to another route, **so that** I can set a landing route and keep old links working. + - **Acceptance Criteria:** + - [ ] A route can redirect to another path (e.g. an empty path `/` redirects to `/home`). + - [ ] A redirect can be computed from the current URL's captured values (e.g. `/u/42` redirects to `/users/42`). + - [ ] After a redirect, the address bar reflects the final destination and the destination screen is shown. + +### R9 — Pre-loading data before a screen appears (`resolve`) + +- **As a** developer, **I want** a route to finish loading its data before its screen is shown, **so that** the visitor never sees a half-built screen. + - **Acceptance Criteria:** + - [ ] A route can declare one or more pieces of data that must be ready before its screen appears. + - [ ] While that data is loading, the current screen stays put and the new screen does not yet appear. + - [ ] Once all declared data is ready, the new screen appears with that data available to its controller. + - [ ] If any declared data fails to load, the new screen does **not** appear and a navigation-failure signal is raised (see R10). + +### R10 — Navigation lifecycle signals + +- **As a** developer, **I want to** be notified as navigation happens, **so that** I can show loading indicators, guard navigation, and report failures. + - **Acceptance Criteria:** + - [ ] A signal is raised when navigation to a new route begins. + - [ ] A signal is raised when navigation completes successfully and the new screen is shown. + - [ ] A signal is raised when navigation fails (including when pre-loaded data fails). + - [ ] A signal is raised when only the URL's query values change on the current route (an update, not a full navigation). + +### R11 — Current URL values available to the screen + +- **As a** developer, **I want** the active screen to read the values captured from the current URL, **so that** it can show the right content (e.g. which user). + - **Acceptance Criteria:** + - [ ] The active screen can read the path placeholders and query values from the current URL by name. + - [ ] When the visitor navigates to the same route with different values (e.g. `/users/42` → `/users/43`), the available values update to reflect the new URL. + +### R12 — Current-route information + +- **As a** developer, **I want to** inspect which route is currently active, **so that** I can read the current screen's metadata (e.g. a page title or its original pattern). + - **Acceptance Criteria:** + - [ ] The developer can read which route is currently active and the definition it was configured with. + - [ ] Any custom fields the developer attached to a route definition are readable from the current-route information. + +### R13 — Manual reload / refresh + +- **As a** developer, **I want to** re-run the current route on demand, **so that** I can refresh a screen's data without a full browser reload. + - **Acceptance Criteria:** + - [ ] The developer can trigger a reload of the current route. + - [ ] On reload, the route's pre-loaded data is fetched again and its screen is rebuilt from scratch, without a full browser page reload. + +### R14 — Query-change reload behavior + +- **As a** developer, **I want** control over whether changing only the query values rebuilds the screen, **so that** I can keep a screen alive across minor URL changes. + - **Acceptance Criteria:** + - [ ] By default, changing only the query portion of the URL does **not** tear down and rebuild the screen; the current-URL values simply update and the R10 "update" signal is raised. + - [ ] The developer can opt a route into rebuilding the screen even when only the query values change. + +--- + +## 3. Scope and Boundaries + +### In-Scope + +- An opt-in routing capability, added separately from the core (mirroring the existing sanitization precedent). +- Route configuration: URL-pattern-to-screen mapping, a fallback route, path parameters (named, optional, and "rest of path"), and query values. +- Case-insensitive matching as a per-route opt-in, and trailing-slash tolerance. +- Developer-selectable URL style: hash-based by default, clean URLs as an opt-in — including the underlying ability to read and change the browser URL that this requires. +- A single view placeholder that renders the current screen and swaps screens on navigation. +- Route screens from inline templates and from remote template files (with reuse of already-loaded templates). +- Per-route controllers with a friendly alias. +- Redirects, including redirects computed from captured URL values. +- Pre-loading route data before the screen appears, with success and failure handling. +- Navigation lifecycle signals (start, success, failure, and query-only update). +- Access to the current URL's values, the current-route information, and a manual reload. +- Per-route control over whether a query-only URL change rebuilds the screen (default: it does not). +- Behavioral parity with AngularJS `ngRoute`, validated by ported test scenarios. + +### Out-of-Scope + +- **Animated screen transitions** — fade/slide effects when screens swap depend on the Animations roadmap item and are excluded here. +- **Nested / named views, multiple simultaneous view placeholders, and state-based routing** — these are `ui-router` features, explicitly a non-goal per the product definition. +- **The classic `angular` namespace wrapper** — exposing routing under a global `angular` object belongs to the Phase 5 compatibility layer. +- **Other Phase 4 roadmap items** — Animations, Package & Distribution, and the Examples folder are separate specifications. +- **Server-side rendering** — a stated product non-goal; the clean-URL style assumes normal browser navigation only. diff --git a/context/spec/040-routing/tasks.md b/context/spec/040-routing/tasks.md new file mode 100644 index 0000000..34b1f69 --- /dev/null +++ b/context/spec/040-routing/tasks.md @@ -0,0 +1,57 @@ +# Tasks: Routing (spec 040) + +- **Functional Spec:** `context/spec/040-routing/functional-spec.md` +- **Technical Considerations:** `context/spec/040-routing/technical-considerations.md` + +Each slice keeps the library in a runnable, green state (`pnpm typecheck && pnpm lint && pnpm test`, and `pnpm build` where noted). Verification is shell-based via Vitest/jsdom — no browser MCP required (this is a library). + +--- + +## Slice 1: `$location` foundation + packaging (hashbang, core getters/setters) + +- [ ] Scaffold `src/location/` + packaging: `@location/*` alias in `tsconfig.json` & `vitest.config.ts`, `./location` entry in `rollup.config.mjs` (`entries` + `tsPathAliases`) and `package.json` `exports`. **[Agent: rollup-build]** +- [ ] `src/location/location-url.ts` — pure hashbang parse/compose helpers (path/search/hash encode-decode). **[Agent: typescript-framework]** +- [ ] `src/location/location.ts` — `createLocation(seams)` pure factory: internal URL state + hashbang mode + core getters/setters (`url`/`path`/`search`/`hash`), browser seams (`locationRef`/`historyRef`/`addEventListenerRef`) defaulting to globals. **[Agent: typescript-framework]** +- [ ] `src/location/location-provider.ts` + `index.ts` — `$LocationProvider` (`$get` wires seams + `$rootScope`), barrel with contract types; register `$location` on `ngModule` (`src/core/ng-module.ts`). **[Agent: typescript-framework]** +- [ ] Verify: unit tests with fake seams (get/set path/search/hash/url); `bootstrapInjector` resolves `$location`; `pnpm typecheck && pnpm lint && pnpm test` green. **[Agent: vitest-testing]** + +## Slice 2: `$location` full parity (HTML5 mode, remaining surface, change events) + +- [ ] Extend `location-url.ts` + `location.ts`: `absUrl`/`protocol`/`host`/`port`/`state`, `replace()`, HTML5 composition. **[Agent: typescript-framework]** +- [ ] `$LocationProvider.html5Mode()` / `hashPrefix()` config getter/setters (spec-034 idiom, frozen at `$get`). **[Agent: typescript-framework]** +- [ ] Digest sync + `$locationChangeStart` (cancelable) / `$locationChangeSuccess` broadcast on `$rootScope`; guarded `$apply` → `invokeExceptionHandler`. **[Agent: typescript-framework]** +- [ ] Verify: unit tests both modes, `replace()`, event broadcast + cancelation, seam-driven URL change; port AngularJS `$location` vectors. **[Agent: vitest-testing]** + +## Slice 3: `ngRoute` skeleton + route matching + `$routeParams` + +- [ ] Scaffold `src/route/` + packaging (`@route/*` alias, `./route` subpath, `ModuleRegistry` merge). **[Agent: rollup-build]** +- [ ] `src/route/route-path.ts` — `pathRegExp` (`:param` / `:param?` / `*wildcard`, `caseInsensitiveMatch`, trailing-slash, query capture). **[Agent: typescript-framework]** +- [ ] `$RouteProvider.when(path, def)` (chainable) + `otherwise(def)`; `src/route/route.ts` `createRoute` core matching on `$locationChangeSuccess` (inline template only — no resolve/redirect yet); `$route.current`/`.routes`. **[Agent: typescript-framework]** +- [ ] `$routeParams` in-place-repopulated factory; `$routeChangeStart`/`$routeChangeSuccess` broadcast; `createModule('ngRoute', [])` wiring. **[Agent: typescript-framework]** +- [ ] Verify: unit — configure routes, drive fake `$location`, assert `$route.current` + `$routeParams` + events + `otherwise` fallback. **[Agent: vitest-testing]** + +## Slice 4: `ngView` renders inline-template routes + +- [ ] `src/route/ng-view.ts` — `ngInclude`-clone directive (`transclude: 'element'`, comment anchor, wrapper-div, stale-token teardown + dual cleanup), child scope, route controller + `controllerAs`, `$viewContentLoaded`; register on `ngRoute`. **[Agent: typescript-framework]** +- [ ] Verify: jsdom integration (`resetRegistry`, `['ng','ngRoute']`) — navigate via fake `$location`, assert screen swap, controller + `controllerAs`, prior-clone teardown. **[Agent: vitest-testing]** + +## Slice 5: Remote templates (`templateUrl`) + +- [ ] `$route` template resolution via `$templateRequest` (`templateUrl` string + fn, `template` fn), cache reuse; `ngView` renders fetched template. **[Agent: typescript-framework]** +- [ ] Verify: jsdom with mock `$templateRequest` — async flush (`$digest()` + `await Promise.resolve()`), cache-reuse, fn forms. **[Agent: vitest-testing]** + +## Slice 6: `resolve` + error handling + +- [ ] Per-navigation `resolve` via `$injector.invoke(fn, null, locals)` + `$q.all`; expose `$route.current.locals` to the route controller; `$routeChangeError` broadcast + `$q` rejection on failure (no view swap). **[Agent: typescript-framework]** +- [ ] Verify: unit — resolve success populates locals, failure → `$routeChangeError` + view intact, sync/async ordering, `$q`-in-digest flush. **[Agent: vitest-testing]** + +## Slice 7: Redirects, reload semantics, params update + +- [ ] `redirectTo` (static + fn via `$location.replace().url(...)`), `RouteRedirectionLoopError` loop detection, `reloadOnSearch`/`reloadOnUrl` → `$routeUpdate` (no teardown), `$route.reload()`, `$route.updateParams()`. **[Agent: typescript-framework]** +- [ ] Verify: unit — static/computed redirect, loop detection, query-only → `$routeUpdate`, `reload()` re-runs resolve, `updateParams()`. **[Agent: vitest-testing]** + +## Slice 8: Parity suites, docs & wrap-up + +- [ ] Port AngularJS parity scenarios (`routeSpec.js`, `routeParamsSpec.js`, `ngViewSpec.js`, `$location` specs). **[Agent: vitest-testing]** +- [ ] README(s) for `src/location` + `src/route`, a routing text diagram under `context/diagrams/` + index link, CLAUDE.md module table + invariants + "Where to look when…", tick roadmap Routing items. **[Agent: typedoc-docs]** +- [ ] Verify: full `pnpm typecheck && pnpm lint && pnpm test && pnpm build` green + 90%+ coverage on `src/location` and `src/route`. **[Agent: vitest-testing]** diff --git a/context/spec/040-routing/technical-considerations.md b/context/spec/040-routing/technical-considerations.md new file mode 100644 index 0000000..75fc96d --- /dev/null +++ b/context/spec/040-routing/technical-considerations.md @@ -0,0 +1,102 @@ + + +# Technical Specification: Routing + +- **Functional Specification:** `context/spec/040-routing/functional-spec.md` +- **Status:** Draft +- **Author(s):** mminasian + +--- + +## 1. High-Level Technical Approach + +Routing ships as a new **opt-in `ngRoute` module** (`src/route/`, subpath `./route`, alias `@route/*`) mirroring the `ngSanitize` packaging precedent. It provides `$route` / `$routeProvider` (route table, URL matching, redirects, `resolve` orchestration, change events), `$routeParams` (live current-params object), and the `ngView` structural directive (a near-clone of `ng-include`'s mechanics keyed off `$route.current`). + +Because `ngRoute` depends on browser-URL read/write that **does not exist in the codebase**, this spec also builds a new **`$location` / `$locationProvider`** service in `src/location/` (alias `@location/*`, subpath `./location`), registered on the **core `ng` module** for AngularJS parity. `$location` is a pure factory behind injectable browser seams (`window.location` / `window.history` / `window.addEventListener`) — the established `$httpBackend` seam pattern — supporting the full parity surface plus hashbang (default) and HTML5 (`html5Mode`) URL styles. + +Everything else reuses shipped collaborators: `$q` (resolve), `$templateRequest` + `$templateCache` (templates), `$controller` (per-route controllers), `$compile` (linking), `$rootScope` (event broadcast + digest sync), and `$injector.invoke` (running `resolve` functions with route locals). **No new `EXCEPTION_HANDLER_CAUSES` token** — the tuple stays at 13. + +Affected systems: `src/core/ng-module.ts` (register `$location`), plus build/packaging config (`tsconfig`, `vitest.config`, `rollup.config.mjs`, `package.json`) for two new subpaths. No changes to existing runtime services. + +--- + +## 2. Proposed Solution & Implementation Plan (The "How") + +### 2.1 Architecture Changes — two new subpaths + +**A. `$location` (core service, new directory `src/location/`)** — registered on `ngModule`, not a separate DI module. Follows the `$http`-lives-in-`src/http`-but-registers-on-`ng` precedent. + +**B. `ngRoute` (opt-in module, new directory `src/route/`)** — its own `createModule('ngRoute', [])`, mirroring `src/sanitize/ng-sanitize-module.ts`. + +Packaging touchpoints for **each** subpath (per the `ngSanitize` template): `tsconfig.json` path alias, `vitest.config.ts` alias, `rollup.config.mjs` (`entries` + `tsPathAliases`; may need the `--max-old-space-size` bump already present), and the `package.json` `exports` triple (`import`/`require`/`types`). `ngRoute` additionally declaration-merges a `ModuleRegistry` key in `@di/di-types`. + +### 2.2 Component Breakdown (new files) + +| File | Responsibility | +| --- | --- | +| `src/location/location.ts` | `createLocation(seams)` pure factory — internal URL state, parse/compose for hashbang + HTML5, getters/setters, digest sync + `$locationChangeStart/Success` broadcast. | +| `src/location/location-provider.ts` | `$LocationProvider` — config-phase `html5Mode()` / `hashPrefix()` getter/setters (spec-034 idiom), `$get` wires browser seams + `$rootScope`. | +| `src/location/location-url.ts` | Pure URL parse/serialize helpers (path/search/hash encode-decode, hashbang vs html5 composition). | +| `src/location/index.ts` | Barrel — `createLocation`, `$LocationProvider`, contract types (`LocationService`, `LocationProvider`). | +| `src/route/route.ts` | `createRoute(seams)` — route table match, redirect resolution, per-navigation `resolve` orchestration via `$q.all` + `$injector.invoke`, `$route.current`/`.routes`, `reload()`, `updateParams()`. | +| `src/route/route-provider.ts` | `$RouteProvider` — `when(path, def)` (chainable), `otherwise(def)`, `eagerInstantiation`; `$get` builds `$route`. | +| `src/route/route-path.ts` | `pathRegExp(pattern, opts)` — compiles `:param` / `:param?` / `*wildcard` into a matcher + param-name list; `caseInsensitiveMatch`, trailing-slash tolerance. | +| `src/route/route-params.ts` | `$routeParams` factory — a mutable object repopulated in place on each successful navigation. | +| `src/route/ng-view.ts` | `ngView` directive — `ng-include` clone keyed off `$route.current` + `$routeChangeSuccess`; instantiates route controller with resolved locals. | +| `src/route/ng-route-module.ts` | `createModule('ngRoute', [])` wiring `$route`, `$routeParams`, `ngView`; `ModuleRegistry` merge. | +| `src/route/index.ts` | Barrel — factories, providers, module, contract types, error classes. | + +### 2.3 `$location` design (full parity) + +- **Read getters:** `absUrl()`, `url()`, `protocol()`, `host()`, `port()`, `path()`, `search()`, `hash()`, `state()`. +- **Fluent setters (chainable, return `this`):** `url(v)`, `path(v)`, `search(k,v | obj)`, `hash(v)`, `state(v)` (HTML5 only), `replace()` (marks the next sync as a history *replace* not *push*). +- **`$locationProvider` config:** `html5Mode(boolean | { enabled, requireBase?, rewriteLinks? })`, `hashPrefix(string)` — default prefix `'!'`. +- **Browser seams (injected, default to globals):** `locationRef` (= `window.location`), `historyRef` (= `window.history`), `addEventListenerRef` (= `window.addEventListener` for `hashchange` + `popstate`). Feature-detected/guarded like `$httpBackend`'s `documentRef`. +- **Digest sync (algorithm):** on browser `hashchange`/`popstate`, the seam handler re-parses the browser URL into internal state and calls a guarded `$rootScope.$apply()` that broadcasts `$locationChangeStart` (cancelable via `event.preventDefault()`) then `$locationChangeSuccess`. When app code mutates `$location`, a `$rootScope` post-digest sync flushes internal state to the browser via `historyRef.pushState`/`replaceState` (or hash assignment) and fires the same events. Guarded because core `$apply` is `try/finally` (wrap → `invokeExceptionHandler`). +- **Simplification (documented deviation):** AngularJS routes browser interaction through a separate `$browser` service (URL polling, deferred). We fold that directly into the `$location` seams — **no `$browser` service** ships. Recorded as an intentional clarity-over-parity divergence. + +### 2.4 `$route` / `$routeProvider` design + +- **`$routeProvider.when(path, route)`** — accepts a route definition: `template` | `templateUrl` (string or fn), `controller` | `controllerAs`, `redirectTo` (string or fn), `resolve` (map of injectables), `reloadOnUrl` / `reloadOnSearch`, `caseInsensitiveMatch`, and arbitrary custom fields (readable via `$route.current`). Chainable. +- **`$routeProvider.otherwise(route)`** — fallback definition (or a `{ redirectTo }`). +- **`$route` service surface:** `routes` (compiled table), `current` (active resolved route incl. `locals`, `params`, `scope`), `reload()`, `updateParams(newParams)`. +- **Navigation lifecycle (algorithm):** listens on `$rootScope.$on('$locationChangeSuccess')`. On URL change: (1) find the first matching `when` (else `otherwise`); (2) if `redirectTo`, compute target and `$location.replace().url(target)`, abort this pass; (3) broadcast `$routeChangeStart(next, last)`; (4) resolve the template (`$templateRequest`/inline) **and** all `resolve` entries via `$injector.invoke(fn, null, locals)` collected into `$q.all`; (5) on success — set `$route.current`, repopulate `$routeParams` in place, broadcast `$routeChangeSuccess(current, previous)` (which `ngView` consumes); (6) on failure — broadcast `$routeChangeError(next, last, rejection)` and leave the current view intact. `reloadOnSearch: false` / `reloadOnUrl: false` route a query-only change to `$routeUpdate` (no teardown) instead of a full navigation. +- **`$routeParams`** repopulated in place (clear keys + copy) so injected references stay live — AngularJS parity. + +### 2.5 `ngView` directive + +- **DDO:** `{ restrict: 'ECA', terminal: true, priority: 400, transclude: 'element', link }` — identical shape to `ngInclude`. +- **DI:** `['$route', '$compile', '$controller', '$injector', '$exceptionHandler', ngViewFactory]`. +- **Behavior:** on `$routeChangeSuccess` (listened via the surrounding scope), tear down the previous clone (stale-token sentinel + `addElementCleanup(placeholder, …)` + `scope.$on('$destroy', …)`, both load-bearing per the ng-include analysis), then render `$route.current`'s already-resolved template into a fresh wrapper `
` after the placeholder comment, create a child scope, expose the resolved `locals`, instantiate the route controller (with `controllerAs`) via `$controller(current.controller, locals, false, current.controllerAs)`, `$compile` the container against the child scope, insert after the placeholder, and emit `$viewContentLoaded`. `autoscroll`/`$anchorScroll` support is **out of scope** (noted deferral). + +### 2.6 Error routing & DI + +- **No new cause token.** `resolve` failures → `$routeChangeError` broadcast + `$q` rejection channel; template-fetch / compile / SCE failures reuse `'$compile'` (the ng-include precedent). `EXCEPTION_HANDLER_CAUSES.length` stays **13**. +- **New error classes** (exported from `@route/index`, plain `Error` subclasses with brand names): e.g. `RouteRedirectionLoopError` for a redirect cycle, and validation errors for malformed `when` patterns. Registration-time validation surfaces synchronously to the caller (the `.directive`/`.component` precedent); runtime failures route as above. + +--- + +## 3. Impact and Risk Analysis + +- **System Dependencies:** New `$location` on `ngModule` (`src/core/ng-module.ts`) — the only core edit; a lazy factory, so non-routing apps pay ~nothing. `ngRoute` depends on `$location`, `$q`, `$templateRequest`, `$templateCache`, `$controller`, `$compile`, `$rootScope`, `$injector`. Build/packaging config gains two subpaths. +- **Potential Risks & Mitigations:** + - **`$location` is 100% new (highest risk).** URL parse/compose for two modes + digest sync + event broadcast. *Mitigation:* isolate pure URL logic in `location-url.ts` with exhaustive unit tests; port AngularJS `$location` spec vectors. + - **`hashchange`/`popstate` + jsdom limitations.** jsdom supports `history.pushState` but throws "Not implemented: navigation" on cross-doc `location.href` mutation and fires hash/pop events inconsistently. *Mitigation:* **all** browser access behind injectable seams; tests inject fake `locationRef`/`historyRef` plain objects and invoke the captured listener manually — never touch real jsdom `location`/`history` in assertions (the `$httpBackend` fake-fetch precedent). + - **`$apply` is `try/finally` not `try/catch`.** A throw in a location/route event handler could escape the digest. *Mitigation:* wrap all seam-driven `$apply` dispatch in `try/catch` → `invokeExceptionHandler` (the `ng-event-directives` / `applyPhaseGuarded` precedent). + - **`$q`-in-digest test flakiness.** `resolve` uses `$q.all`, whose continuations run via `$evalAsync`. *Mitigation:* test helper alternates `$rootScope.$digest()` (drains `$evalAsync`) with `await Promise.resolve()` (drains template `fetch`), documented in the route test files. + - **`ngView` wrapper-`
` CSS caveat.** Inherits ng-include's documented direct-child-selector caveat. *Mitigation:* documented in file TSDoc + README; parity tests assert on descendant selectors. + - **Rollup heap.** Two more TS programs in the build. *Mitigation:* the `--max-old-space-size=8192` flag is already present; bump if the build OOMs. + +--- + +## 4. Testing Strategy + +- **Unit — `$location`:** pure `location-url.ts` parse/compose (both modes, encode/decode, edge cases); `createLocation` with fake seams — getters/setters, `replace()`, `state()`, digest-sync flush, `$locationChange*` broadcast + cancelation. Port AngularJS `$location` spec vectors. +- **Unit — `route-path.ts`:** `:param` / `:param?` / `*wildcard`, case-insensitive, trailing-slash, query capture. +- **Unit — `$route`:** matching + `otherwise`, static & computed `redirectTo` (+ loop detection), `resolve` success/failure ordering, `$routeParams` in-place repopulation, `reload()`, `reloadOnSearch`/`reloadOnUrl` → `$routeUpdate`, all four events. Fake `$location`/`$templateRequest` via last-wins `.factory(...)` override. +- **Integration — `ngView`:** jsdom bootstrap (`resetRegistry` + `createInjector([ngModule, appModule])`, `appModule` requires `['ng','ngRoute']`); drive navigation via the injected fake `$location`, flush with `$digest()` + `await Promise.resolve()`; assert screen swap, controller instantiation + `controllerAs`, teardown of the prior clone, `$viewContentLoaded`. +- **Parity suite:** port scenarios from AngularJS `ngRoute` (`routeSpec.js`, `routeParamsSpec.js`, `ngViewSpec.js`) and `$location` specs. +- **Coverage:** 90%+ on `src/location` and `src/route` (the enforced per-module threshold). From be4df8d4795db47b798dded82155c8408ad0ac40 Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Mon, 6 Jul 2026 15:21:09 -0400 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20$location=20foundation=20?= =?UTF-8?q?=E2=80=94=20hashbang=20mode,=20core=20getters/setters,=20ng=20r?= =?UTF-8?q?egistration=20(spec=20040=20slice=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- context/spec/040-routing/tasks.md | 10 +- package.json | 5 + rollup.config.mjs | 2 + src/core/ng-module.ts | 15 + src/index.ts | 23 + src/location/__tests__/location-di.test.ts | 173 ++++++++ src/location/__tests__/location-url.test.ts | 364 ++++++++++++++++ src/location/__tests__/location.test.ts | 442 ++++++++++++++++++++ src/location/index.ts | 27 ++ src/location/location-provider.ts | 65 +++ src/location/location-types.ts | 91 ++++ src/location/location-url.ts | 271 ++++++++++++ src/location/location.ts | 251 +++++++++++ tsconfig.json | 3 +- vitest.config.ts | 1 + 15 files changed, 1737 insertions(+), 6 deletions(-) create mode 100644 src/location/__tests__/location-di.test.ts create mode 100644 src/location/__tests__/location-url.test.ts create mode 100644 src/location/__tests__/location.test.ts create mode 100644 src/location/index.ts create mode 100644 src/location/location-provider.ts create mode 100644 src/location/location-types.ts create mode 100644 src/location/location-url.ts create mode 100644 src/location/location.ts diff --git a/context/spec/040-routing/tasks.md b/context/spec/040-routing/tasks.md index 34b1f69..d6570c0 100644 --- a/context/spec/040-routing/tasks.md +++ b/context/spec/040-routing/tasks.md @@ -9,11 +9,11 @@ Each slice keeps the library in a runnable, green state (`pnpm typecheck && pnpm ## Slice 1: `$location` foundation + packaging (hashbang, core getters/setters) -- [ ] Scaffold `src/location/` + packaging: `@location/*` alias in `tsconfig.json` & `vitest.config.ts`, `./location` entry in `rollup.config.mjs` (`entries` + `tsPathAliases`) and `package.json` `exports`. **[Agent: rollup-build]** -- [ ] `src/location/location-url.ts` — pure hashbang parse/compose helpers (path/search/hash encode-decode). **[Agent: typescript-framework]** -- [ ] `src/location/location.ts` — `createLocation(seams)` pure factory: internal URL state + hashbang mode + core getters/setters (`url`/`path`/`search`/`hash`), browser seams (`locationRef`/`historyRef`/`addEventListenerRef`) defaulting to globals. **[Agent: typescript-framework]** -- [ ] `src/location/location-provider.ts` + `index.ts` — `$LocationProvider` (`$get` wires seams + `$rootScope`), barrel with contract types; register `$location` on `ngModule` (`src/core/ng-module.ts`). **[Agent: typescript-framework]** -- [ ] Verify: unit tests with fake seams (get/set path/search/hash/url); `bootstrapInjector` resolves `$location`; `pnpm typecheck && pnpm lint && pnpm test` green. **[Agent: vitest-testing]** +- [x] Scaffold `src/location/` + packaging: `@location/*` alias in `tsconfig.json` & `vitest.config.ts`, `./location` entry in `rollup.config.mjs` (`entries` + `tsPathAliases`) and `package.json` `exports`. **[Agent: rollup-build]** +- [x] `src/location/location-url.ts` — pure hashbang parse/compose helpers (path/search/hash encode-decode). **[Agent: typescript-framework]** +- [x] `src/location/location.ts` — `createLocation(seams)` pure factory: internal URL state + hashbang mode + core getters/setters (`url`/`path`/`search`/`hash`), browser seams (`locationRef`/`historyRef`/`addEventListenerRef`) defaulting to globals. **[Agent: typescript-framework]** +- [x] `src/location/location-provider.ts` + `index.ts` — `$LocationProvider` (`$get` wires seams + `$rootScope`), barrel with contract types; register `$location` on `ngModule` (`src/core/ng-module.ts`). **[Agent: typescript-framework]** +- [x] Verify: unit tests with fake seams (get/set path/search/hash/url); `bootstrapInjector` resolves `$location`; `pnpm typecheck && pnpm lint && pnpm test` green. **[Agent: vitest-testing]** ## Slice 2: `$location` full parity (HTML5 mode, remaining surface, change events) diff --git a/package.json b/package.json index d5000cf..4127822 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,11 @@ "import": "./dist/esm/forms/index.mjs", "require": "./dist/cjs/forms/index.cjs", "types": "./dist/types/forms/index.d.ts" + }, + "./location": { + "import": "./dist/esm/location/index.mjs", + "require": "./dist/cjs/location/index.cjs", + "types": "./dist/types/location/index.d.ts" } }, "repository": "https://github.com/Mgrdich/my_own_angularjs.git", diff --git a/rollup.config.mjs b/rollup.config.mjs index 598045d..7594b9f 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -41,6 +41,7 @@ const entries = [ { name: 'cache/index', input: 'src/cache/index.ts' }, { name: 'http/index', input: 'src/http/index.ts' }, { name: 'forms/index', input: 'src/forms/index.ts' }, + { name: 'location/index', input: 'src/location/index.ts' }, ]; // Path aliases declared in `tsconfig.json` are used across the codebase @@ -66,6 +67,7 @@ const tsPathAliases = { '@cache/*': ['src/cache/*'], '@http/*': ['src/http/*'], '@forms/*': ['src/forms/*'], + '@location/*': ['src/location/*'], }; const bundleConfigs = entries.map((entry) => ({ diff --git a/src/core/ng-module.ts b/src/core/ng-module.ts index a396fb4..89248ab 100644 --- a/src/core/ng-module.ts +++ b/src/core/ng-module.ts @@ -114,6 +114,8 @@ import { numberFilterFactory } from '@filter/number'; import { orderByFilterFactory } from '@filter/order-by'; import { $InterpolateProvider } from '@interpolate/interpolate-provider'; import type { InterpolateService } from '@interpolate/interpolate-types'; +import { $LocationProvider } from '@location/location-provider'; +import type { LocationService } from '@location/location-types'; import { $SceDelegateProvider } from '@sce/sce-delegate-provider'; import { $SceProvider } from '@sce/sce-provider'; import type { SceDelegateService, SceService } from '@sce/sce-types'; @@ -142,6 +144,7 @@ declare module '@di/di-types' { $cacheFactory: CacheFactory; $httpBackend: HttpBackend; $http: HttpService; + $location: LocationService; uppercaseFilter: FilterFn; lowercaseFilter: FilterFn; jsonFilter: FilterFn; @@ -162,6 +165,7 @@ declare module '@di/di-types' { $templateCacheProvider: $TemplateCacheProvider; $templateRequestProvider: $TemplateRequestProvider; $httpProvider: $HttpProvider; + $locationProvider: $LocationProvider; }; }; } @@ -286,6 +290,17 @@ export const ngModule = createModule('ng', []) // '$httpBackend','$cacheFactory']` are declared up front (the interceptor / // caching slices use `$injector` / `$cacheFactory`). .provider<'$http', HttpService, $HttpProvider>('$http', $HttpProvider) + // `$location` (spec 040 Slice 1) — the browser URL service, hashbang mode. + // Registered as a `.provider(...)` so config blocks reach the config-phase + // `hashPrefix()` getter/setter (`config(['$locationProvider', …])`); `$get` + // (zero deps in Slice 1) builds the service via the pure `createLocation` + // factory, whose browser seams (`window.location` / `window.history`) + // default by feature detection — a windowless environment yields the + // documented in-memory service. Digest sync, `$locationChangeStart` / + // `$locationChangeSuccess`, `html5Mode()`, and the read-only browser-URL + // getters land in Slice 2 (`$get` grows `$rootScope` / + // `$exceptionHandler` deps then). + .provider<'$location', LocationService, $LocationProvider>('$location', $LocationProvider) .provider('$sceDelegate', $SceDelegateProvider) .provider('$sce', $SceProvider) .provider('$interpolate', $InterpolateProvider) diff --git a/src/index.ts b/src/index.ts index 9064921..fc13df2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -232,6 +232,29 @@ export type { ResponseTransform, } from './http/index'; +export { + composeAppUrl, + composeHashbangHash, + createLocation, + DEFAULT_HASH_PREFIX, + normalizePath, + parseAppUrl, + parseHashbangUrl, + parseSearchString, + serializeSearch, + $LocationProvider, +} from './location/index'; +export type { + CreateLocationArgs, + HistoryRef, + LocationRef, + LocationService, + ParsedAppUrl, + SearchParams, + SearchSetValue, + SearchValue, +} from './location/index'; + export { createModelOptions, defaultModelOptions, diff --git a/src/location/__tests__/location-di.test.ts b/src/location/__tests__/location-di.test.ts new file mode 100644 index 0000000..4a21440 --- /dev/null +++ b/src/location/__tests__/location-di.test.ts @@ -0,0 +1,173 @@ +/** + * DI integration tests for `$location` / `$LocationProvider` + * (spec 040 Slice 1 / FS R4). + * + * Boots the canonical `ngModule` via `bootstrapInjector` (which prepends + * `ngModule` automatically — the forms-suite precedent), so the + * `.provider('$location', $LocationProvider)` registration in + * `src/core/ng-module.ts` is exercised end-to-end: + * + * 1. **Resolution + singleton** — `injector.get('$location')` yields the + * service; repeated gets return the SAME reference (lazy `$get`, cached). + * 2. **Real-browser seam** — `$get` is zero-dep and feature-detects jsdom's + * `window.location`, so construction parses the real page URL and setters + * write the real `window.location.hash` (jsdom implements fragment-only + * navigation, so no "Not implemented: navigation" throw). + * 3. **Config-phase `hashPrefix`** — a `config(['$locationProvider', …])` + * block takes effect (`''` → plain `#/path` URLs); the value is FROZEN at + * `$get` (later mutations are ignored); a non-string argument throws + * `TypeError('hashPrefix expects a string argument')`. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { bootstrapInjector } from '@bootstrap/index'; +import { createModule, resetRegistry } from '@di/module'; +import { $LocationProvider } from '@location/location-provider'; + +/** + * Register an `'app'` module whose config block receives `$locationProvider`. + * Deps stay `[]` — `bootstrapInjector` prepends the `ngModule` OBJECT itself, + * and the string name `'ng'` may have been evicted by a neighbouring + * `resetRegistry()` (the `provide.test.ts` precedent). + */ +function registerConfigApp(configure: (p: $LocationProvider) => void): void { + createModule('app', []).config(['$locationProvider', configure]); +} + +afterEach(() => { + resetRegistry(); + // Keep the shared jsdom URL clean for neighbouring tests in this file — + // fragment-only writes are the ONLY navigation `$location` performs. + window.location.hash = ''; +}); + +// ──────────────────────────────────────────────────────────────────────────── +// resolution + singleton +// ──────────────────────────────────────────────────────────────────────────── + +describe('$location DI — resolution', () => { + it('bootstrapInjector([]) resolves $location with the four getter/setter pairs live', () => { + const injector = bootstrapInjector([]); + const $location = injector.get('$location'); + expect($location.path()).toBeTypeOf('string'); + expect($location.search()).toBeTypeOf('object'); + expect($location.hash()).toBeTypeOf('string'); + expect($location.url()).toBeTypeOf('string'); + }); + + it('is a singleton — repeated injector.get calls return the SAME reference', () => { + const injector = bootstrapInjector([]); + expect(injector.get('$location')).toBe(injector.get('$location')); + }); + + it('injector.has("$location") is true on the ng chain', () => { + const injector = bootstrapInjector([]); + expect(injector.has('$location')).toBe(true); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// real-browser seam (jsdom window.location) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$location DI — jsdom browser seam', () => { + it('parses the page URL at first get (default "!" prefix)', () => { + const injector = bootstrapInjector([]); + window.location.hash = '#!/pre?x=1'; + const $location = injector.get('$location'); + expect($location.path()).toBe('/pre'); + expect($location.search()).toEqual({ x: '1' }); + }); + + it('setters write the real window.location.hash with the leading "#!"', () => { + const injector = bootstrapInjector([]); + const $location = injector.get('$location'); + $location.path('/di').search('q', '1'); + expect(window.location.hash).toBe('#!/di?q=1'); + }); + + it('treats a foreign in-page anchor as the empty app URL', () => { + const injector = bootstrapInjector([]); + window.location.hash = '#top'; + const $location = injector.get('$location'); + expect($location.path()).toBe('/'); + expect($location.search()).toEqual({}); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// config-phase hashPrefix +// ──────────────────────────────────────────────────────────────────────────── + +describe('$locationProvider — config-phase hashPrefix', () => { + it('a config block setting hashPrefix("") takes effect (plain #/path URLs)', () => { + registerConfigApp((p) => { + p.hashPrefix(''); + }); + const injector = bootstrapInjector(['app']); + window.location.hash = '#/cfg?a=1'; + const $location = injector.get('$location'); + expect($location.path()).toBe('/cfg'); + expect($location.search()).toEqual({ a: '1' }); + $location.path('/next'); + expect(window.location.hash).toBe('#/next?a=1'); + }); + + it('getter form inside a config block reads the current prefix (default "!")', () => { + let observed = ''; + registerConfigApp((p) => { + observed = p.hashPrefix(); + }); + bootstrapInjector(['app']); + expect(observed).toBe('!'); + }); + + it('setter form returns the provider for chaining', () => { + let chained: unknown; + registerConfigApp((p) => { + chained = p.hashPrefix('!!'); + expect(p.hashPrefix()).toBe('!!'); + }); + bootstrapInjector(['app']); + expect(chained).toBeInstanceOf($LocationProvider); + }); + + it('the prefix is FROZEN at $get — a mutation after the service exists is ignored', () => { + // Object holder (not a bare `let`) so control-flow analysis does not + // collapse the closure-assigned value to its `null` initializer. + const holder: { provider: $LocationProvider | null } = { provider: null }; + registerConfigApp((p) => { + holder.provider = p; + }); + const injector = bootstrapInjector(['app']); + const $location = injector.get('$location'); // forces $get — '!' baked in + const captured = holder.provider; + if (captured === null) { + throw new Error('config block did not run'); + } + captured.hashPrefix('@@'); + $location.path('/frozen'); + expect(window.location.hash).toBe('#!/frozen'); + }); + + it('a non-string argument throws TypeError from inside the config block (surfaces synchronously)', () => { + registerConfigApp((p) => { + p.hashPrefix(42 as unknown as string); + }); + expect(() => bootstrapInjector(['app'])).toThrow(new TypeError('hashPrefix expects a string argument')); + }); + + it('a non-string argument throws TypeError on a direct provider instance too', () => { + const provider = new $LocationProvider(); + expect(() => { + provider.hashPrefix(null as unknown as string); + }).toThrow(new TypeError('hashPrefix expects a string argument')); + }); + + it('the empty string is a VALID prefix on a direct provider instance', () => { + const provider = new $LocationProvider(); + provider.hashPrefix(''); + expect(provider.hashPrefix()).toBe(''); + }); +}); diff --git a/src/location/__tests__/location-url.test.ts b/src/location/__tests__/location-url.test.ts new file mode 100644 index 0000000..8c3f4c5 --- /dev/null +++ b/src/location/__tests__/location-url.test.ts @@ -0,0 +1,364 @@ +/** + * Tests for the pure hashbang URL parse/serialize helpers behind `$location` + * (spec 040 Slice 1 / FS R4). + * + * Every function in `location-url.ts` is PURE (no browser access, no shared + * state), so this suite exercises the parse/compose logic standalone: + * + * 1. **`normalizePath`** — canonical leading-`/` form. + * 2. **Search-string semantics** — AngularJS `parseKeyValue` / `toKeyValue` + * parity: bare key → `true`, repeated keys → `string[]`, `false` + * round-trips as the literal `key=false` pair, encode/decode of reserved + * characters + unicode, malformed-escape tolerance (kept verbatim). + * 3. **App-URL round-trips** — `parseAppUrl` / `composeAppUrl` over + * path/search/hash, right-to-left splitting (`?` inside the fragment + * belongs to the hash). + * 4. **Hashbang parsing from full browser URLs** — `parseHashbangUrl` / + * `composeHashbangHash` with the default `'!'` prefix AND the empty + * `''` prefix, plus the foreign-fragment rule (a plain `#top` anchor + * yields the empty app URL). + * + * Assertion vectors ported from the AngularJS `locationSpec.js` hashbang + * basics where they fit the Slice 1 surface. + */ + +import { describe, expect, it } from 'vitest'; + +import { + composeAppUrl, + composeHashbangHash, + normalizePath, + parseAppUrl, + parseHashbangUrl, + parseSearchString, + serializeSearch, +} from '@location/location-url'; + +// ──────────────────────────────────────────────────────────────────────────── +// normalizePath +// ──────────────────────────────────────────────────────────────────────────── + +describe('normalizePath', () => { + it('normalizes the empty string to "/"', () => { + expect(normalizePath('')).toBe('/'); + }); + + it('prepends a missing leading slash', () => { + expect(normalizePath('users')).toBe('/users'); + }); + + it('leaves an already-leading-slash path unchanged', () => { + expect(normalizePath('/users')).toBe('/users'); + }); + + it('leaves the bare root "/" unchanged', () => { + expect(normalizePath('/')).toBe('/'); + }); + + it('leaves a multi-segment path unchanged', () => { + expect(normalizePath('/a/b/c')).toBe('/a/b/c'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// parseSearchString +// ──────────────────────────────────────────────────────────────────────────── + +describe('parseSearchString', () => { + it('parses ordinary key=value pairs', () => { + expect(parseSearchString('a=1&b=2')).toEqual({ a: '1', b: '2' }); + }); + + it('parses a bare key (no "=") as true', () => { + expect(parseSearchString('flag')).toEqual({ flag: true }); + }); + + it('parses a mix of bare keys and valued keys', () => { + expect(parseSearchString('flag&a=1')).toEqual({ flag: true, a: '1' }); + }); + + it('parses an empty value ("a=") as the empty string', () => { + expect(parseSearchString('a=')).toEqual({ a: '' }); + }); + + it('collects repeated keys into a string array', () => { + expect(parseSearchString('a=1&a=2')).toEqual({ a: ['1', '2'] }); + }); + + it('keeps repeated-key order across three occurrences', () => { + expect(parseSearchString('a=1&a=2&a=3')).toEqual({ a: ['1', '2', '3'] }); + }); + + it('coerces a boolean entry to a string when a repeat forces an array', () => { + // Documented divergence: AngularJS produces mixed `[true, 'x']`; this + // project keeps arrays uniformly `string[]`. + expect(parseSearchString('a&a=x')).toEqual({ a: ['true', 'x'] }); + }); + + it('skips empty pairs ("a=1&&b=2")', () => { + expect(parseSearchString('a=1&&b=2')).toEqual({ a: '1', b: '2' }); + }); + + it('skips pairs with an empty key ("=v")', () => { + expect(parseSearchString('=v')).toEqual({}); + }); + + it('returns an empty map for an empty string', () => { + expect(parseSearchString('')).toEqual({}); + }); + + it('decodes percent-encoded keys and values', () => { + expect(parseSearchString('a%20key=a%20value')).toEqual({ 'a key': 'a value' }); + }); + + it('decodes reserved characters (&, =, ?, #) from their escapes', () => { + expect(parseSearchString('q=a%26b%3Dc%3Fd%23e')).toEqual({ q: 'a&b=c?d#e' }); + }); + + it('decodes unicode escapes', () => { + expect(parseSearchString('q=%E4%B8%AD%E6%96%87')).toEqual({ q: '中文' }); + }); + + it('keeps a malformed percent-escape verbatim instead of throwing', () => { + expect(parseSearchString('q=%zz')).toEqual({ q: '%zz' }); + }); + + it('keeps a malformed escape in a KEY verbatim too', () => { + expect(parseSearchString('%zz=1')).toEqual({ '%zz': '1' }); + }); + + it('stores prototype-named keys as own properties (no prototype read-through)', () => { + const result = parseSearchString('constructor=x&toString=y'); + expect(result).toEqual({ constructor: 'x', toString: 'y' }); + expect(Object.prototype.hasOwnProperty.call(result, 'constructor')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(result, 'toString')).toBe(true); + }); + + it('collects a repeated prototype-named key into an array (own-property guard)', () => { + expect(parseSearchString('constructor=a&constructor=b')).toEqual({ constructor: ['a', 'b'] }); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// serializeSearch +// ──────────────────────────────────────────────────────────────────────────── + +describe('serializeSearch', () => { + it('serializes ordinary string values as key=value pairs', () => { + expect(serializeSearch({ a: '1', b: '2' })).toBe('a=1&b=2'); + }); + + it('serializes true as a bare key', () => { + expect(serializeSearch({ flag: true })).toBe('flag'); + }); + + it('serializes false as the literal key=false pair', () => { + expect(serializeSearch({ off: false })).toBe('off=false'); + }); + + it('serializes an array as one pair per entry, in order', () => { + expect(serializeSearch({ a: ['1', '2', '3'] })).toBe('a=1&a=2&a=3'); + }); + + it('returns the empty string for an empty map', () => { + expect(serializeSearch({})).toBe(''); + }); + + it('percent-encodes keys and values', () => { + expect(serializeSearch({ 'a key': 'a value' })).toBe('a%20key=a%20value'); + }); + + it('percent-encodes reserved characters in values', () => { + expect(serializeSearch({ q: 'a&b=c?d#e' })).toBe('q=a%26b%3Dc%3Fd%23e'); + }); + + it('percent-encodes unicode values', () => { + expect(serializeSearch({ q: '中文' })).toBe('q=%E4%B8%AD%E6%96%87'); + }); + + it('iterates keys in insertion order', () => { + expect(serializeSearch({ z: '1', a: '2', m: '3' })).toBe('z=1&a=2&m=3'); + }); + + it('round-trips through parseSearchString (bare key, arrays, unicode)', () => { + const original = { flag: true, a: ['1', '2'], q: '中 文&=' }; + expect(parseSearchString(serializeSearch(original))).toEqual(original); + }); + + it('round-trips a false value: serialize → "off=false" → parse → string "false"', () => { + // `false` serializes as the literal pair; parsing yields the STRING + // 'false' (removal is the setter's job, not the serializer's). + expect(parseSearchString(serializeSearch({ off: false }))).toEqual({ off: 'false' }); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// parseAppUrl +// ──────────────────────────────────────────────────────────────────────────── + +describe('parseAppUrl', () => { + it('parses a full path?search#hash app URL', () => { + expect(parseAppUrl('/users/42?q=1#frag')).toEqual({ + path: '/users/42', + search: { q: '1' }, + hash: 'frag', + }); + }); + + it('parses the empty string to the empty app URL', () => { + expect(parseAppUrl('')).toEqual({ path: '/', search: {}, hash: '' }); + }); + + it('normalizes a path-only input without a leading slash', () => { + expect(parseAppUrl('users')).toEqual({ path: '/users', search: {}, hash: '' }); + }); + + it('parses a path + search without a hash', () => { + expect(parseAppUrl('/p?x=1&y=2')).toEqual({ path: '/p', search: { x: '1', y: '2' }, hash: '' }); + }); + + it('parses a path + hash without a search', () => { + expect(parseAppUrl('/p#frag')).toEqual({ path: '/p', search: {}, hash: 'frag' }); + }); + + it('parses a hash-only input at the root path', () => { + expect(parseAppUrl('#frag')).toEqual({ path: '/', search: {}, hash: 'frag' }); + }); + + it('splits right-to-left: a "?" inside the fragment belongs to the hash', () => { + expect(parseAppUrl('/p#a?b')).toEqual({ path: '/p', search: {}, hash: 'a?b' }); + }); + + it('decodes percent-encoded path segments', () => { + expect(parseAppUrl('/a%20b/c%3Ad')).toEqual({ path: '/a b/c:d', search: {}, hash: '' }); + }); + + it('decodes a percent-encoded hash', () => { + expect(parseAppUrl('/p#fr%20ag')).toEqual({ path: '/p', search: {}, hash: 'fr ag' }); + }); + + it('keeps a malformed percent-escape in the path verbatim', () => { + expect(parseAppUrl('/a%zz')).toEqual({ path: '/a%zz', search: {}, hash: '' }); + }); + + it('parses a bare-flag search inside an app URL', () => { + expect(parseAppUrl('/p?flag')).toEqual({ path: '/p', search: { flag: true }, hash: '' }); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// composeAppUrl +// ──────────────────────────────────────────────────────────────────────────── + +describe('composeAppUrl', () => { + it('composes path + search + hash with the separators', () => { + expect(composeAppUrl({ path: '/users/42', search: { q: '1' }, hash: 'frag' })).toBe('/users/42?q=1#frag'); + }); + + it('omits the "?" when the search map is empty', () => { + expect(composeAppUrl({ path: '/p', search: {}, hash: 'frag' })).toBe('/p#frag'); + }); + + it('omits the "#" when the hash is empty', () => { + expect(composeAppUrl({ path: '/p', search: { a: '1' }, hash: '' })).toBe('/p?a=1'); + }); + + it('composes the bare root to "/"', () => { + expect(composeAppUrl({ path: '/', search: {}, hash: '' })).toBe('/'); + }); + + it('encodes path segments while keeping the "/" separators', () => { + expect(composeAppUrl({ path: '/a b/c', search: {}, hash: '' })).toBe('/a%20b/c'); + }); + + it('percent-encodes reserved path characters (documented divergence from AngularJS relaxed encoding)', () => { + expect(composeAppUrl({ path: '/users/a:b', search: {}, hash: '' })).toBe('/users/a%3Ab'); + }); + + it('encodes the hash via encodeURIComponent', () => { + expect(composeAppUrl({ path: '/p', search: {}, hash: 'a b#c' })).toBe('/p#a%20b%23c'); + }); + + it('round-trips through parseAppUrl (encoded path, search shapes, hash)', () => { + const original = { path: '/a b/中', search: { flag: true, a: ['1', '2'], q: 'x&y' }, hash: 'fr ag' }; + expect(parseAppUrl(composeAppUrl(original))).toEqual(original); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// parseHashbangUrl +// ──────────────────────────────────────────────────────────────────────────── + +describe('parseHashbangUrl', () => { + it('parses a full browser URL behind the "!" prefix', () => { + expect(parseHashbangUrl('http://host/#!/users/42?q=1#frag', '!')).toEqual({ + path: '/users/42', + search: { q: '1' }, + hash: 'frag', + }); + }); + + it('parses behind the empty "" prefix (plain #/path URLs)', () => { + expect(parseHashbangUrl('http://host/#/a?b=1', '')).toEqual({ path: '/a', search: { b: '1' }, hash: '' }); + }); + + it('parses a URL with a base path before the fragment', () => { + expect(parseHashbangUrl('http://host/base/index.html#!/x', '!')).toEqual({ path: '/x', search: {}, hash: '' }); + }); + + it('yields the empty app URL when the browser URL has NO fragment', () => { + expect(parseHashbangUrl('http://host/base/', '!')).toEqual({ path: '/', search: {}, hash: '' }); + }); + + it('yields the empty app URL for a FOREIGN fragment (in-page anchor #top)', () => { + expect(parseHashbangUrl('http://host/#top', '!')).toEqual({ path: '/', search: {}, hash: '' }); + }); + + it('yields the root path when the fragment is exactly the prefix ("#!")', () => { + expect(parseHashbangUrl('http://host/#!', '!')).toEqual({ path: '/', search: {}, hash: '' }); + }); + + it('keeps a "?" inside the app fragment part of the app hash (right-to-left split)', () => { + expect(parseHashbangUrl('http://host/#!/p#a?b', '!')).toEqual({ path: '/p', search: {}, hash: 'a?b' }); + }); + + it('with the "" prefix EVERY fragment is an app URL (no foreign-fragment escape hatch)', () => { + expect(parseHashbangUrl('http://host/#top', '')).toEqual({ path: '/top', search: {}, hash: '' }); + }); + + it('decodes percent-encoded parts of the embedded app URL', () => { + expect(parseHashbangUrl('http://host/#!/a%20b?q=c%20d', '!')).toEqual({ + path: '/a b', + search: { q: 'c d' }, + hash: '', + }); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// composeHashbangHash +// ──────────────────────────────────────────────────────────────────────────── + +describe('composeHashbangHash', () => { + it('includes the leading "#" and the prefix', () => { + expect(composeHashbangHash({ path: '/a', search: { b: '1' }, hash: '' }, '!')).toBe('#!/a?b=1'); + }); + + it('composes with the empty "" prefix', () => { + expect(composeHashbangHash({ path: '/a', search: {}, hash: '' }, '')).toBe('#/a'); + }); + + it('composes the bare root to "#!/"', () => { + expect(composeHashbangHash({ path: '/', search: {}, hash: '' }, '!')).toBe('#!/'); + }); + + it('includes the app hash after its own "#"', () => { + expect(composeHashbangHash({ path: '/a', search: {}, hash: 'frag' }, '!')).toBe('#!/a#frag'); + }); + + it('round-trips through parseHashbangUrl when appended to a base URL', () => { + const original = { path: '/users/42', search: { q: '1', flag: true }, hash: 'frag' }; + const browserUrl = `http://host/base/${composeHashbangHash(original, '!')}`; + expect(parseHashbangUrl(browserUrl, '!')).toEqual(original); + }); +}); diff --git a/src/location/__tests__/location.test.ts b/src/location/__tests__/location.test.ts new file mode 100644 index 0000000..b92c44f --- /dev/null +++ b/src/location/__tests__/location.test.ts @@ -0,0 +1,442 @@ +/** + * Tests for `createLocation` — the pure `$location` factory in hashbang mode + * (spec 040 Slice 1 / FS R4). + * + * The browser is a plain fake seam object (`{ href, hash }` satisfies + * {@link LocationRef} completely — the `createHttpBackend` seam precedent), + * so the suite never touches jsdom's real `window.location`: + * + * 1. **Construction parse** — internal state seeds ONCE from + * `locationRef.href` (later `href` mutations are invisible); foreign / no + * fragment seeds the empty app URL. + * 2. **Getter/setter pairs** — `path` / `search` / `hash` / `url`, incl. + * fluent chaining and the synchronous `locationRef.hash` write shape + * (leading `#` + prefix, e.g. `'#!/a?b=1'`). + * 3. **`search()` semantics** — copy-on-get, key-form set/remove + * (null/undefined removes, number → string, `false` → `key=false`), + * wholesale object replace with a defensive copy. + * 4. **`url(value)` section reset** — absent sections clear. + * 5. **`locationRef: null`** — the documented in-memory mode. + * 6. **Custom hash prefixes** — `''` (plain `#/path`) and multi-char. + * + * Assertion vectors ported from the AngularJS `locationSpec.js` hashbang + * basics where they fit the Slice 1 surface. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { createLocation, DEFAULT_HASH_PREFIX, type HistoryRef, type LocationRef } from '@location/location'; +import type { LocationService } from '@location/location-types'; + +/** Build a fake browser seam — a plain object IS a complete `LocationRef`. */ +function fakeRef(href: string): LocationRef { + return { href, hash: '' }; +} + +/** Boot a service over a fake seam and return both. */ +function boot(href: string, hashPrefix?: string): { $location: LocationService; ref: LocationRef } { + const ref = fakeRef(href); + const $location = createLocation(hashPrefix === undefined ? { locationRef: ref } : { locationRef: ref, hashPrefix }); + return { $location, ref }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// construction parse +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — construction parse from href', () => { + it('parses path, search, and hash from a hashbang href', () => { + const { $location } = boot('http://host/base/#!/users/42?q=1#frag'); + expect($location.path()).toBe('/users/42'); + expect($location.search()).toEqual({ q: '1' }); + expect($location.hash()).toBe('frag'); + }); + + it('exposes the composed app URL via url()', () => { + const { $location } = boot('http://host/#!/users/42?q=1#frag'); + expect($location.url()).toBe('/users/42?q=1#frag'); + }); + + it('seeds the empty app URL when the href has no fragment', () => { + const { $location } = boot('http://host/base/'); + expect($location.path()).toBe('/'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + expect($location.url()).toBe('/'); + }); + + it('seeds the empty app URL for a FOREIGN fragment (plain in-page anchor)', () => { + const { $location } = boot('http://host/#top'); + expect($location.path()).toBe('/'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + }); + + it('reads href exactly ONCE — later href mutations are invisible', () => { + const { $location, ref } = boot('http://host/#!/first'); + ref.href = 'http://host/#!/second'; + expect($location.path()).toBe('/first'); + }); + + it('does not write to the browser at construction (getters are read-only)', () => { + const { $location, ref } = boot('http://host/#!/a?b=1'); + $location.path(); + $location.search(); + $location.hash(); + $location.url(); + expect(ref.hash).toBe(''); + }); + + it('parses repeated search keys and bare flags from the href', () => { + const { $location } = boot('http://host/#!/p?a=1&a=2&flag'); + expect($location.search()).toEqual({ a: ['1', '2'], flag: true }); + }); + + it('DEFAULT_HASH_PREFIX is "!" (AngularJS parity)', () => { + expect(DEFAULT_HASH_PREFIX).toBe('!'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// path() +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — path getter/setter', () => { + it('sets the path and returns the service (fluent)', () => { + const { $location } = boot('http://host/#!/old'); + const returned = $location.path('/new'); + expect(returned).toBe($location); + expect($location.path()).toBe('/new'); + }); + + it('writes the recomposed hashbang hash to locationRef.hash SYNCHRONOUSLY, with the leading "#"', () => { + const { $location, ref } = boot('http://host/#!/old?b=1'); + $location.path('/new'); + expect(ref.hash).toBe('#!/new?b=1'); + }); + + it('prepends a missing leading slash', () => { + const { $location, ref } = boot('http://host/#!/old'); + $location.path('users'); + expect($location.path()).toBe('/users'); + expect(ref.hash).toBe('#!/users'); + }); + + it('normalizes the empty string to "/"', () => { + const { $location, ref } = boot('http://host/#!/old'); + $location.path(''); + expect($location.path()).toBe('/'); + expect(ref.hash).toBe('#!/'); + }); + + it('preserves search and hash across a path change', () => { + const { $location } = boot('http://host/#!/old?q=1#frag'); + $location.path('/new'); + expect($location.search()).toEqual({ q: '1' }); + expect($location.hash()).toBe('frag'); + expect($location.url()).toBe('/new?q=1#frag'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// search() +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — search getter/setter', () => { + it('getter returns a COPY — mutating the returned map does not leak into the service', () => { + const { $location } = boot('http://host/#!/p?a=1'); + const copy = $location.search(); + copy['a'] = 'mutated'; + copy['injected'] = 'x'; + expect($location.search()).toEqual({ a: '1' }); + }); + + it('getter copies ARRAY values too — pushing into a returned array does not leak', () => { + const { $location } = boot('http://host/#!/p?a=1&a=2'); + const copy = $location.search(); + const arr = copy['a']; + if (!Array.isArray(arr)) { + throw new Error('expected a repeated-key array'); + } + arr.push('3'); + expect($location.search()).toEqual({ a: ['1', '2'] }); + }); + + it('key-form sets a string value (fluent + synchronous write)', () => { + const { $location, ref } = boot('http://host/#!/p'); + const returned = $location.search('q', 'term'); + expect(returned).toBe($location); + expect($location.search()).toEqual({ q: 'term' }); + expect(ref.hash).toBe('#!/p?q=term'); + }); + + it('key-form normalizes a number to its string form', () => { + const { $location, ref } = boot('http://host/#!/p'); + $location.search('page', 42); + expect($location.search()).toEqual({ page: '42' }); + expect(ref.hash).toBe('#!/p?page=42'); + }); + + it('key-form true serializes as a bare flag key', () => { + const { $location, ref } = boot('http://host/#!/p'); + $location.search('flag', true); + expect($location.search()).toEqual({ flag: true }); + expect(ref.hash).toBe('#!/p?flag'); + }); + + it('key-form false is KEPT and serializes as the literal key=false pair', () => { + const { $location, ref } = boot('http://host/#!/p'); + $location.search('off', false); + expect($location.search()).toEqual({ off: false }); + expect(ref.hash).toBe('#!/p?off=false'); + }); + + it('key-form array value serializes as repeated keys', () => { + const { $location, ref } = boot('http://host/#!/p'); + $location.search('a', ['1', '2']); + expect($location.search()).toEqual({ a: ['1', '2'] }); + expect(ref.hash).toBe('#!/p?a=1&a=2'); + }); + + it('key-form defensively copies a passed array', () => { + const { $location } = boot('http://host/#!/p'); + const passed = ['1', '2']; + $location.search('a', passed); + passed.push('3'); + expect($location.search()).toEqual({ a: ['1', '2'] }); + }); + + it('key-form null REMOVES the key', () => { + const { $location, ref } = boot('http://host/#!/p?a=1&b=2'); + $location.search('a', null); + expect($location.search()).toEqual({ b: '2' }); + expect(ref.hash).toBe('#!/p?b=2'); + }); + + it('key-form undefined REMOVES the key too', () => { + const { $location, ref } = boot('http://host/#!/p?a=1&b=2'); + $location.search('b', undefined); + expect($location.search()).toEqual({ a: '1' }); + expect(ref.hash).toBe('#!/p?a=1'); + }); + + it('removing a missing key is a harmless no-op on the map', () => { + const { $location } = boot('http://host/#!/p?a=1'); + $location.search('nope', null); + expect($location.search()).toEqual({ a: '1' }); + }); + + it('object-form replaces the WHOLE map wholesale', () => { + const { $location, ref } = boot('http://host/#!/p?old=1&stale=2'); + $location.search({ fresh: 'x' }); + expect($location.search()).toEqual({ fresh: 'x' }); + expect(ref.hash).toBe('#!/p?fresh=x'); + }); + + it('object-form with an empty map clears every key', () => { + const { $location, ref } = boot('http://host/#!/p?a=1#frag'); + $location.search({}); + expect($location.search()).toEqual({}); + expect(ref.hash).toBe('#!/p#frag'); + }); + + it('object-form takes a defensive copy — later caller-side mutations do not leak', () => { + const { $location } = boot('http://host/#!/p'); + const passed: Record = { a: '1' }; + $location.search(passed); + passed['a'] = 'mutated'; + passed['injected'] = 'x'; + expect($location.search()).toEqual({ a: '1' }); + }); + + it('search changes preserve path and hash', () => { + const { $location } = boot('http://host/#!/p?a=1#frag'); + $location.search('a', '2'); + expect($location.path()).toBe('/p'); + expect($location.hash()).toBe('frag'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// hash() +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — hash getter/setter', () => { + it('sets the app hash and returns the service (fluent)', () => { + const { $location, ref } = boot('http://host/#!/p'); + const returned = $location.hash('frag'); + expect(returned).toBe($location); + expect($location.hash()).toBe('frag'); + expect(ref.hash).toBe('#!/p#frag'); + }); + + it('stores the value VERBATIM (no "#" stripping)', () => { + const { $location } = boot('http://host/#!/p'); + $location.hash('#already'); + expect($location.hash()).toBe('#already'); + }); + + it('clears the hash with the empty string', () => { + const { $location, ref } = boot('http://host/#!/p#frag'); + $location.hash(''); + expect($location.hash()).toBe(''); + expect(ref.hash).toBe('#!/p'); + }); + + it('hash changes preserve path and search', () => { + const { $location, ref } = boot('http://host/#!/p?q=1'); + $location.hash('frag'); + expect($location.path()).toBe('/p'); + expect($location.search()).toEqual({ q: '1' }); + expect(ref.hash).toBe('#!/p?q=1#frag'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// url() +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — url getter/setter', () => { + it('getter composes the current /path?search#hash', () => { + const { $location } = boot('http://host/#!/p?a=1#frag'); + expect($location.url()).toBe('/p?a=1#frag'); + }); + + it('setter sets path, search, AND hash in one shot (fluent + synchronous write)', () => { + const { $location, ref } = boot('http://host/#!/old'); + const returned = $location.url('/users/42?q=1#frag'); + expect(returned).toBe($location); + expect($location.path()).toBe('/users/42'); + expect($location.search()).toEqual({ q: '1' }); + expect($location.hash()).toBe('frag'); + expect(ref.hash).toBe('#!/users/42?q=1#frag'); + }); + + it('RESETS absent sections — url("/plain") clears search and hash', () => { + const { $location, ref } = boot('http://host/#!/old?q=1#frag'); + $location.url('/plain'); + expect($location.path()).toBe('/plain'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + expect(ref.hash).toBe('#!/plain'); + }); + + it('resets only the hash when the search section is present', () => { + const { $location } = boot('http://host/#!/old?q=1#frag'); + $location.url('/p?a=2'); + expect($location.search()).toEqual({ a: '2' }); + expect($location.hash()).toBe(''); + }); + + it('normalizes a path-less url value to the root', () => { + const { $location, ref } = boot('http://host/#!/old?q=1'); + $location.url(''); + expect($location.path()).toBe('/'); + expect($location.search()).toEqual({}); + expect(ref.hash).toBe('#!/'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// fluent chaining + write cadence +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — fluent chaining', () => { + it('chains path().search().hash() and composes the combined URL', () => { + const { $location, ref } = boot('http://host/#!/'); + $location.path('/users/42').search({ q: '1' }).hash('frag'); + expect($location.url()).toBe('/users/42?q=1#frag'); + expect(ref.hash).toBe('#!/users/42?q=1#frag'); + }); + + it('writes to the browser on EVERY setter call in the chain (synchronous cadence)', () => { + const { $location, ref } = boot('http://host/#!/'); + const observed: string[] = []; + $location.path('/a'); + observed.push(ref.hash); + $location.search('q', '1'); + observed.push(ref.hash); + $location.hash('h'); + observed.push(ref.hash); + expect(observed).toEqual(['#!/a', '#!/a?q=1', '#!/a?q=1#h']); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// in-memory mode (locationRef: null) +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — locationRef: null (in-memory mode)', () => { + it('seeds the empty defaults: path "/", search {}, hash ""', () => { + const $location = createLocation({ locationRef: null }); + expect($location.path()).toBe('/'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + expect($location.url()).toBe('/'); + }); + + it('getters/setters behave identically — only the browser write is skipped', () => { + const $location = createLocation({ locationRef: null }); + $location.path('/mem').search('a', '1').hash('h'); + expect($location.url()).toBe('/mem?a=1#h'); + expect($location.search()).toEqual({ a: '1' }); + }); + + it('url(value) resets sections in memory too', () => { + const $location = createLocation({ locationRef: null }); + $location.url('/x?y=1#z'); + $location.url('/plain'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// custom hash prefixes + the ignored historyRef seam +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — custom hash prefix', () => { + it('parses plain "#/path" URLs with the empty "" prefix', () => { + const { $location } = boot('http://host/#/users?q=1', ''); + expect($location.path()).toBe('/users'); + expect($location.search()).toEqual({ q: '1' }); + }); + + it('writes "#/path" (no bang) with the empty "" prefix', () => { + const { $location, ref } = boot('http://host/#/old', ''); + $location.path('/new'); + expect(ref.hash).toBe('#/new'); + }); + + it('under the "" prefix a "#!/…" fragment is NOT foreign — "!" becomes path text', () => { + // With the empty prefix every fragment is an app URL; the '!' is part + // of the path. + const { $location } = boot('http://host/#!/x', ''); + expect($location.path()).toBe('/!/x'); + }); + + it('supports a multi-character prefix', () => { + const { $location, ref } = boot('http://host/#my-prefix/start', 'my-prefix'); + expect($location.path()).toBe('/start'); + $location.path('/next'); + expect(ref.hash).toBe('#my-prefix/next'); + }); + + it('a fragment NOT starting with the custom prefix is foreign', () => { + const { $location } = boot('http://host/#!/x', 'my-prefix'); + expect($location.path()).toBe('/'); + }); +}); + +describe('createLocation — historyRef seam (Slice 1: accepted, ignored)', () => { + it('never invokes pushState/replaceState in Slice 1', () => { + const pushState = vi.fn(); + const replaceState = vi.fn(); + const historyRef: HistoryRef = { pushState, replaceState }; + const ref = fakeRef('http://host/#!/a'); + const $location = createLocation({ locationRef: ref, historyRef }); + $location.path('/b').search('q', '1').hash('h').url('/c'); + expect(pushState).not.toHaveBeenCalled(); + expect(replaceState).not.toHaveBeenCalled(); + }); +}); diff --git a/src/location/index.ts b/src/location/index.ts new file mode 100644 index 0000000..7fb2ad1 --- /dev/null +++ b/src/location/index.ts @@ -0,0 +1,27 @@ +/** + * Public barrel for the `@location` module — the browser URL service + * (`$location`, `$LocationProvider`; spec 040 Slice 1). + * + * Exposes the pure {@link createLocation} factory (injectable browser seams + * — the `createHttpBackend` precedent), the config-phase + * {@link $LocationProvider}, the pure hashbang URL parse/serialize helpers, + * and the public contract types. The DI registration lives on `ngModule` + * (`src/core/ng-module.ts`), not here — mirroring the `@async` / `@cache` / + * `@http` precedent where a service ships a pure factory AND a separate + * `ngModule` registration. + */ + +export { createLocation, DEFAULT_HASH_PREFIX } from './location'; +export type { CreateLocationArgs, HistoryRef, LocationRef } from './location'; +export { $LocationProvider } from './location-provider'; +export type { LocationService, SearchSetValue } from './location-types'; +export { + composeAppUrl, + composeHashbangHash, + normalizePath, + parseAppUrl, + parseHashbangUrl, + parseSearchString, + serializeSearch, +} from './location-url'; +export type { ParsedAppUrl, SearchParams, SearchValue } from './location-url'; diff --git a/src/location/location-provider.ts b/src/location/location-provider.ts new file mode 100644 index 0000000..43ae831 --- /dev/null +++ b/src/location/location-provider.ts @@ -0,0 +1,65 @@ +/** + * `$LocationProvider` — config-phase configurator for the `$location` + * service (spec 040 Slice 1). + * + * Slice 1 ships the `hashPrefix()` getter/setter (the spec-034 + * `$compileProvider` idiom — no-arg → get, with-arg → validate + store + + * return `this` for chaining) and a `$get` that builds the service via the + * pure {@link createLocation} factory with the REAL browser seams + * (feature-detected inside the factory — a windowless environment yields the + * documented in-memory service). `html5Mode()` and the digest-sync wiring + * (`$rootScope` dependency, `hashchange` listener, `$locationChange*` + * events) land in Slice 2 — `$get` grows its dependency array then. + * + * The stored prefix is read once at `$get` time, so a config-block mutation + * after the run phase begins has no effect (AngularJS parity — the frozen-at- + * `$get` contract every config-phase provider here follows). + * + * @example + * ```ts + * createModule('app', ['ng']).config(['$locationProvider', (p) => { + * p.hashPrefix(''); // plain `#/path` URLs instead of `#!/path` + * }]); + * ``` + */ + +import { createLocation, DEFAULT_HASH_PREFIX } from './location'; +import type { LocationService } from './location-types'; + +export class $LocationProvider { + // `$$` prefix mirrors the AngularJS "internal / not part of the public + // API" convention (`$InterpolateProvider.$$startSymbol` precedent). Kept + // private so callers are routed through the validated setter. + private $$hashPrefix: string = DEFAULT_HASH_PREFIX; + + /** + * Config-phase getter/setter for the hashbang prefix (spec-034 idiom). + * + * - Called WITH a string → validates the argument, stores it, and returns + * `this` for chaining. The empty string is VALID (plain `#/path` URLs). + * - Called with NO argument → returns the current prefix. + * + * The value in force at `$get` time is baked into the produced service — + * later mutations are ignored. + */ + hashPrefix(): string; + hashPrefix(prefix: string): this; + hashPrefix(prefix?: string): string | this { + if (prefix === undefined) { + return this.$$hashPrefix; + } + if (typeof prefix !== 'string') { + throw new TypeError('hashPrefix expects a string argument'); + } + this.$$hashPrefix = prefix; + return this; + } + + /** + * Injector-facing factory. Zero dependencies in Slice 1 — the browser + * seams default inside {@link createLocation} (feature-detected + * `window.location` / `window.history`). Slice 2 adds `$rootScope` (digest + * sync + `$locationChange*` broadcast) and `$exceptionHandler` here. + */ + $get = [(): LocationService => createLocation({ hashPrefix: this.$$hashPrefix })] as const; +} diff --git a/src/location/location-types.ts b/src/location/location-types.ts new file mode 100644 index 0000000..61dc8cf --- /dev/null +++ b/src/location/location-types.ts @@ -0,0 +1,91 @@ +/** + * Public contract types for the `$location` service (spec 040 Slice 1). + * + * {@link LocationService} is the run-phase surface apps program against — + * the getter/setter pairs for the app-URL triple (`path` / `search` / + * `hash`) plus the composed `url` accessor. Slice 2 widens it with the + * read-only browser-URL getters (`absUrl` / `protocol` / `host` / `port` / + * `state`), `replace()`, and the digest-synced `$locationChangeStart` / + * `$locationChangeSuccess` events — the Slice 1 surface is designed so those + * bolt on additively. + * + * The seam shapes (`LocationRef` / `HistoryRef` / `CreateLocationArgs`) live + * next to the factory in `src/location/location.ts`, mirroring how + * `CreateHttpBackendArgs` lives in `src/http/http-backend.ts`. + */ + +import type { SearchParams } from './location-url'; + +/** + * The value forms accepted by the two-argument `search(key, value)` setter: + * + * - `string` / `string[]` — stored as-is (arrays are defensively copied). + * - `number` — normalized to its string form (`42` → `'42'`). + * - `true` — a bare flag key (serializes without `=`). + * - `false` — kept and serialized as the literal pair `key=false`. + * - `null` / `undefined` — REMOVES the key (AngularJS parity). + */ +export type SearchSetValue = string | number | boolean | string[] | null | undefined; + +/** + * `$location` — the browser URL service (hashbang mode; spec 040 Slice 1). + * + * All setters are FLUENT (return the service for chaining) and write the + * composed hashbang URL to the browser synchronously in Slice 1 (Slice 2 + * moves the write to a digest-scheduled sync). All getters read the + * service's INTERNAL state — the browser URL is parsed once at construction + * and re-parsed on browser-driven changes only from Slice 2 (`hashchange` + * wiring). + * + * @example + * ```ts + * $location.path('/users/42').search({ q: '1' }).hash('frag'); + * $location.url(); // '/users/42?q=1#frag' + * $location.path(); // '/users/42' + * $location.search(); // { q: '1' } (a copy — mutations don't leak back) + * ``` + */ +export interface LocationService { + /** Get the current path — always with a leading `/` (`'/'` at the root). */ + path(): string; + /** + * Set the path. A missing leading `/` is prepended (`path('users')` → + * `'/users'`); the empty string normalizes to `'/'`. + */ + path(value: string): LocationService; + + /** + * Get the current search map — a COPY (arrays included), so mutating the + * returned object never corrupts the service's internal state. + */ + search(): SearchParams; + /** + * Replace the WHOLE search map with a (defensive) copy of `params`. + * AngularJS's single-string whole-search form (`search('a=b&c')`) is NOT + * supported — pass the object form. + */ + search(params: SearchParams): LocationService; + /** + * Set (or remove) a SINGLE search key. A `null` / `undefined` value + * removes the key; a `number` is normalized to its string form; `true` + * renders as a bare flag key. See {@link SearchSetValue}. + */ + search(key: string, value: SearchSetValue): LocationService; + + /** Get the current app-URL fragment (`''` when absent). */ + hash(): string; + /** + * Set the app-URL fragment. Stored VERBATIM (no `#` stripping — pass + * `'frag'`, not `'#frag'`), matching AngularJS's string-through setter. + */ + hash(value: string): LocationService; + + /** Get the composed app URL — `/path?a=b#frag` (see `composeAppUrl`). */ + url(): string; + /** + * Set path, search, AND hash in one shot from an app-URL string + * (`'/users/42?q=1#frag'`). Sections absent from the string RESET to + * their empty defaults (`url('/plain')` clears search and hash). + */ + url(value: string): LocationService; +} diff --git a/src/location/location-url.ts b/src/location/location-url.ts new file mode 100644 index 0000000..168023c --- /dev/null +++ b/src/location/location-url.ts @@ -0,0 +1,271 @@ +/** + * Pure URL parse/serialize helpers for `$location`'s hashbang mode (spec 040 + * Slice 1). + * + * The unit of currency here is the **app URL** — the `/path?search#hash` + * string an application navigates within, embedded in the browser URL's + * fragment behind a hash prefix (default `'!'`): + * + * ```text + * http://host/base/#!/users/42?q=1#frag + * └──────────────┘ + * the app URL: path = '/users/42' + * search = { q: '1' } + * hash = 'frag' + * ``` + * + * Every function in this file is PURE — no browser access, no shared state — + * so the parse/compose logic is exhaustively unit-testable standalone (tech + * §3 mitigation for the "100% new `$location`" risk). `createLocation` + * (`src/location/location.ts`) composes these into the stateful service; the + * HTML5-mode composition variants land in Slice 2 alongside `html5Mode()`. + * + * **Search-string semantics (AngularJS `parseKeyValue` / `toKeyValue` + * parity):** a key with a `true` value renders as a bare key (`?flag`); + * repeated keys become arrays (`?a=1&a=2` → `{ a: ['1', '2'] }`); a key + * without `=` parses as `true`. + * + * **Documented encoding divergences from AngularJS:** + * + * - AngularJS uses relaxed encoders (`encodeUriSegment` / `encodeUriQuery`) + * that leave characters like `@`, `:`, `$`, `,`, `;` unescaped in path + * segments and query parts. This project uses the standard + * `encodeURIComponent` / `decodeURIComponent` pair for simplicity and + * correctness — round-trips are lossless, but the SERIALIZED form + * percent-encodes more characters than upstream (e.g. `/users/a:b` → + * `/users/a%3Ab`). Decoding accepts both forms, so URLs produced by + * AngularJS parse identically. + * - When a repeated search key forces an array, AngularJS can produce mixed + * `[true, 'x']` arrays; this project coerces boolean entries to strings + * (`true` → `'true'`) so array values are uniformly `string[]`. + * - A malformed percent-escape (`'%zz'`) is kept VERBATIM instead of + * throwing — the safe-decode fallback mirrors AngularJS's + * `tryDecodeURIComponent` (which drops the pair; we keep it, favoring + * visibility over silence). + */ + +/** + * A single decoded search (query) value: + * + * - `string` — the ordinary `key=value` form. + * - `boolean` — `true` for a bare key (`?flag`); `false` round-trips as the + * literal string pair `key=false`. + * - `string[]` — a repeated key (`?a=1&a=2`). + */ +export type SearchValue = string | boolean | string[]; + +/** + * The decoded search (query) map — the shape `$location.search()` returns + * and `serializeSearch` consumes. + */ +export type SearchParams = Record; + +/** + * The parsed app-URL triple — the internal state `$location` maintains and + * the interchange shape between the parse and compose halves of this file. + */ +export interface ParsedAppUrl { + /** The decoded path, always with a leading `/` (empty input → `'/'`). */ + path: string; + /** The decoded search map (see {@link SearchParams}). */ + search: SearchParams; + /** The decoded fragment WITHIN the app URL (`''` when absent). */ + hash: string; +} + +/** + * Decode one percent-encoded component, keeping the input VERBATIM when it + * is malformed (`decodeURIComponent` throws on stray `%` sequences). Never + * throws — parsing a hand-typed browser URL must not crash the service. + */ +function tryDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** + * Percent-encode a path string SEGMENT-WISE — each `/`-separated segment is + * `encodeURIComponent`-encoded so the separators survive while the segment + * contents round-trip losslessly (see the file-level encoding-divergence + * note). + */ +function encodePath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/'); +} + +/** Decode a percent-encoded path segment-wise (inverse of {@link encodePath}). */ +function decodePath(path: string): string { + return path.split('/').map(tryDecodeURIComponent).join('/'); +} + +/** + * Normalize a path to its canonical leading-`/` form: `''` → `'/'`, `'users'` + * → `'/users'`, `'/users'` stays unchanged. Used both by the parser and by + * `$location.path(value)`'s setter normalization. + */ +export function normalizePath(path: string): string { + if (path === '') { + return '/'; + } + return path.startsWith('/') ? path : `/${path}`; +} + +/** + * Coerce an existing scalar search value into a repeated-key ARRAY ENTRY. + * Boolean entries are stringified (`true` → `'true'`) so arrays stay + * uniformly `string[]` — a documented divergence from AngularJS's mixed + * arrays (see the file-level note). + */ +function toArrayEntry(value: string | boolean): string { + return typeof value === 'string' ? value : String(value); +} + +/** + * Parse a raw search string (WITHOUT the leading `?`) into the decoded + * {@link SearchParams} map — AngularJS `parseKeyValue` semantics: + * + * - `'a=1&b=2'` → `{ a: '1', b: '2' }` + * - `'flag'` → `{ flag: true }` (bare key) + * - `'a=1&a=2'` → `{ a: ['1', '2'] }` (repeated key) + * - Empty pairs (`'a=1&&b=2'`) and empty keys are skipped. + * + * Keys and values are decoded via the safe (never-throwing) decoder. An + * own-property guard keeps prototype keys (`'constructor'`, …) from reading + * through to `Object.prototype`. + */ +export function parseSearchString(raw: string): SearchParams { + const search: SearchParams = {}; + for (const pair of raw.split('&')) { + if (pair === '') { + continue; + } + const eqIndex = pair.indexOf('='); + const key = tryDecodeURIComponent(eqIndex === -1 ? pair : pair.slice(0, eqIndex)); + if (key === '') { + continue; + } + const value: string | boolean = eqIndex === -1 ? true : tryDecodeURIComponent(pair.slice(eqIndex + 1)); + if (!Object.prototype.hasOwnProperty.call(search, key)) { + search[key] = value; + continue; + } + const existing = search[key]; + if (Array.isArray(existing)) { + existing.push(toArrayEntry(value)); + } else if (existing !== undefined) { + search[key] = [toArrayEntry(existing), toArrayEntry(value)]; + } + } + return search; +} + +/** + * Serialize a {@link SearchParams} map into a search string (WITHOUT the + * leading `?`) — AngularJS `toKeyValue` semantics: + * + * - `true` → the bare key (`flag`). + * - `false` → the literal pair `key=false` (removal is the setter's job — + * `$location.search(key, null)` — not the serializer's). + * - `string[]` → one `key=entry` pair per entry, in order. + * - Anything else → `key=String(value)`. + * + * Keys iterate in insertion order (`Object.entries`). Returns `''` for an + * empty map — the caller decides whether to emit the `?`. + */ +export function serializeSearch(search: SearchParams): string { + const parts: string[] = []; + for (const [key, value] of Object.entries(search)) { + const encodedKey = encodeURIComponent(key); + if (value === true) { + parts.push(encodedKey); + } else if (Array.isArray(value)) { + for (const entry of value) { + parts.push(`${encodedKey}=${encodeURIComponent(entry)}`); + } + } else { + parts.push(`${encodedKey}=${encodeURIComponent(String(value))}`); + } + } + return parts.join('&'); +} + +/** + * Parse an app-URL string (`/path?a=b#frag`) into the decoded + * {@link ParsedAppUrl} triple. The string is split RIGHT-TO-LEFT — first the + * `#` fragment, then the `?` search, then the path remainder — so a `?` + * inside the fragment (`/p#a?b`) belongs to the hash, matching how browsers + * and AngularJS carve up a fragment-embedded URL. An empty or path-less + * input yields path `'/'`. + */ +export function parseAppUrl(appUrl: string): ParsedAppUrl { + let rest = appUrl; + let hash = ''; + const hashIndex = rest.indexOf('#'); + if (hashIndex !== -1) { + hash = tryDecodeURIComponent(rest.slice(hashIndex + 1)); + rest = rest.slice(0, hashIndex); + } + let search: SearchParams = {}; + const searchIndex = rest.indexOf('?'); + if (searchIndex !== -1) { + search = parseSearchString(rest.slice(searchIndex + 1)); + rest = rest.slice(0, searchIndex); + } + return { path: normalizePath(decodePath(rest)), search, hash }; +} + +/** + * Compose the app-URL string `/path?a=b#frag` from a {@link ParsedAppUrl} + * triple (inverse of {@link parseAppUrl}). The `?` / `#` separators are + * emitted only when their section is non-empty; the path is encoded + * segment-wise and the hash via `encodeURIComponent` (see the file-level + * encoding-divergence note). + */ +export function composeAppUrl(parsed: ParsedAppUrl): string { + const searchString = serializeSearch(parsed.search); + return ( + encodePath(parsed.path) + + (searchString === '' ? '' : `?${searchString}`) + + (parsed.hash === '' ? '' : `#${encodeURIComponent(parsed.hash)}`) + ); +} + +/** + * Parse an ABSOLUTE browser URL in hashbang mode into the app-URL triple: + * the fragment after the first `#` is expected to start with the hash + * prefix (`'!'` by default → `#!/users/42?q=1#frag`); the remainder parses + * via {@link parseAppUrl}. + * + * A URL with NO fragment, or with a FOREIGN fragment that does not start + * with the prefix (e.g. a plain in-page anchor `#top`), yields the EMPTY app + * URL (`{ path: '/', search: {}, hash: '' }`) — the service starts at the + * root rather than misreading an anchor as an app path. (AngularJS applies + * extra `#`-only compatibility rewrites here; Slice 1 keeps the simple + * prefix-or-nothing rule, documented for the Slice 2 parity pass.) + */ +export function parseHashbangUrl(absUrl: string, hashPrefix: string): ParsedAppUrl { + const hashIndex = absUrl.indexOf('#'); + if (hashIndex === -1) { + return { path: '/', search: {}, hash: '' }; + } + const fragment = absUrl.slice(hashIndex + 1); + if (!fragment.startsWith(hashPrefix)) { + return { path: '/', search: {}, hash: '' }; + } + return parseAppUrl(fragment.slice(hashPrefix.length)); +} + +/** + * Compose the full browser HASH portion for the triple — the string written + * to `location.hash` on every `$location` mutation. INCLUDES the leading + * `#`: `composeHashbangHash({ path: '/a', … }, '!')` → `'#!/a'`. Browsers + * accept hash assignment with or without the leading `#`; including it keeps + * fake seam objects in tests byte-exact with what a real `location.hash` + * reads back. + */ +export function composeHashbangHash(parsed: ParsedAppUrl, hashPrefix: string): string { + return `#${hashPrefix}${composeAppUrl(parsed)}`; +} diff --git a/src/location/location.ts b/src/location/location.ts new file mode 100644 index 0000000..8d8cd5a --- /dev/null +++ b/src/location/location.ts @@ -0,0 +1,251 @@ +/** + * `createLocation` — the PURE factory behind the `$location` service + * (spec 040 Slice 1). + * + * Follows the `createHttpBackend` seam precedent (`src/http/http-backend.ts`): + * every browser touchpoint is an injected, narrowly-typed seam + * ({@link LocationRef} / {@link HistoryRef}) that defaults to the real + * `window.location` / `window.history` via feature detection, so the factory + * is unit-testable with plain fake objects and never trips jsdom's + * "Not implemented: navigation" throw (tech §3 mitigation). + * + * Slice 1 ships HASHBANG mode only: the app URL (`/path?search#hash`) lives + * in the browser URL's fragment behind the hash prefix (default `'!'`). + * Internal state is parsed ONCE from `locationRef.href` at construction; + * each fluent setter recomposes the hashbang hash and writes it to + * `locationRef.hash` SYNCHRONOUSLY via the private `$$writeToBrowser` + * internal. Slice 2 moves that write to a digest-scheduled sync, adds + * `replace()` (history replace vs push), HTML5 mode (where `historyRef` + * becomes load-bearing), the read-only browser-URL getters, and the + * `$locationChange*` events — the seams are shaped now so those bolt on + * without changing this signature. + * + * **Null-`locationRef` behavior (documented):** passing `locationRef: null` + * (or running where `window` is undefined) yields a fully-functional + * IN-MEMORY service — state seeds empty (`path '/'`, `search {}`, + * `hash ''`) and `$$writeToBrowser` is a guarded no-op. Getters/setters + * behave identically; only the browser write is skipped. + * + * @example + * ```ts + * import { createLocation } from 'my-own-angularjs/location'; + * + * const fakeLocation = { href: 'http://host/#!/users/42?q=1', hash: '#!/users/42?q=1' }; + * const $location = createLocation({ locationRef: fakeLocation }); + * + * $location.path(); // '/users/42' + * $location.search(); // { q: '1' } + * $location.path('/next'); // fluent — returns the service + * fakeLocation.hash; // '#!/next?q=1' (written synchronously) + * ``` + */ + +import type { LocationService, SearchSetValue } from './location-types'; +import { + composeAppUrl, + composeHashbangHash, + normalizePath, + parseAppUrl, + parseHashbangUrl, + type ParsedAppUrl, + type SearchParams, + type SearchValue, +} from './location-url'; + +/** The default hash prefix — hashbang URLs read `#!/path` (AngularJS parity). */ +export const DEFAULT_HASH_PREFIX = '!'; + +/** + * The minimal `window.location` surface the service touches — a narrow + * structural seam (the `JsonpDocument` precedent). Slice 1 READS `href` once + * at construction and WRITES `hash` on each setter; Slice 2's `absUrl()` / + * `protocol()` / `host()` / `port()` getters will read `href` live. Tests + * inject a plain `{ href, hash }` object. + */ +export interface LocationRef { + /** The full absolute browser URL (read at construction; live in Slice 2). */ + href: string; + /** The fragment portion — written (including the leading `#`) on each setter. */ + hash: string; +} + +/** + * The minimal `window.history` surface — RESERVED for Slice 2 (HTML5 mode's + * `pushState` / `replaceState` and the `replace()` semantics). Accepted in + * {@link CreateLocationArgs} NOW so the seam shape is stable across slices; + * Slice 1 never invokes it. + */ +export interface HistoryRef { + pushState(data: unknown, title: string, url?: string | null): void; + replaceState(data: unknown, title: string, url?: string | null): void; +} + +/** + * Arguments accepted by {@link createLocation}. + */ +export interface CreateLocationArgs { + /** + * Optional `window.location` seam. `undefined` (not supplied) → + * feature-detect the global `window.location`; an explicit `null` (or a + * windowless environment) → the in-memory mode documented on the file + * header. Tests inject a plain `{ href, hash }` object. + */ + readonly locationRef?: LocationRef | null; + /** + * Optional `window.history` seam — reserved for Slice 2 (see + * {@link HistoryRef}). Accepted and ignored in Slice 1. + */ + readonly historyRef?: HistoryRef | null; + /** The hashbang prefix after `#`. Defaults to `'!'` (AngularJS parity). */ + readonly hashPrefix?: string; +} + +/** + * Copy a search map defensively — one level deep plus array cloning, which + * covers every {@link SearchValue} shape (strings and booleans are + * immutable). Used by the `search()` getter and both setter forms so callers + * can never alias the service's internal state. + */ +function copySearch(search: SearchParams): SearchParams { + const copy: SearchParams = {}; + for (const [key, value] of Object.entries(search)) { + copy[key] = Array.isArray(value) ? [...value] : value; + } + return copy; +} + +/** + * Normalize a `search(key, value)` scalar into its stored + * {@link SearchValue} form: numbers stringify (`42` → `'42'`), arrays are + * defensively copied, strings and booleans pass through. `null` / + * `undefined` never reach here — the setter routes them to key removal. + */ +function normalizeSearchValue(value: string | number | boolean | string[]): SearchValue { + if (Array.isArray(value)) { + return [...value]; + } + return typeof value === 'number' ? String(value) : value; +} + +/** + * Create a `$location` service (hashbang mode). + * + * Parses `locationRef.href` into the internal app-URL triple at construction + * (empty defaults when the seam is `null`), then serves the four fluent + * getter/setter pairs (`path` / `search` / `hash` / `url`) documented on + * {@link LocationService}. Every setter synchronously recomposes the + * hashbang hash and writes it to `locationRef.hash` (Slice 1 behavior — the + * digest-scheduled sync lands in Slice 2). + * + * @param args - Optional browser seams + hash prefix (see {@link CreateLocationArgs}). + * @returns The `$location` service. + */ +export function createLocation(args: CreateLocationArgs = {}): LocationService { + const hashPrefix = args.hashPrefix ?? DEFAULT_HASH_PREFIX; + // Feature-detect `window.location`. `undefined` (the seam not supplied) + // falls back to the global; an explicit `null` (or a missing global) means + // "no browser" → in-memory mode (the `documentRef` precedent in + // `createHttpBackend`). + const locationRef: LocationRef | null = + args.locationRef !== undefined ? args.locationRef : typeof window !== 'undefined' ? window.location : null; + + // The internal app-URL triple — the single source of truth for every + // getter. Seeded from the browser URL when a seam is present, else the + // empty defaults (documented null-locationRef behavior). + let currentPath = '/'; + let currentSearch: SearchParams = {}; + let currentHash = ''; + + if (locationRef !== null) { + const parsed = parseHashbangUrl(locationRef.href, hashPrefix); + currentPath = parsed.path; + currentSearch = parsed.search; + currentHash = parsed.hash; + } + + /** The current triple as a {@link ParsedAppUrl} — feed for the composers. */ + const currentTriple = (): ParsedAppUrl => ({ path: currentPath, search: currentSearch, hash: currentHash }); + + /** + * Private internal (deliberately NOT on {@link LocationService}): compose + * the hashbang hash for the current triple and write it to the browser. + * Slice 1 calls it synchronously from every setter; Slice 2 replaces the + * call sites with a digest-scheduled sync + push/replace dispatch. Guarded + * no-op in in-memory mode. + */ + const $$writeToBrowser = (): void => { + if (locationRef === null) { + return; + } + locationRef.hash = composeHashbangHash(currentTriple(), hashPrefix); + }; + + function path(): string; + function path(value: string): LocationService; + function path(value?: string): string | LocationService { + if (value === undefined) { + return currentPath; + } + currentPath = normalizePath(value); + $$writeToBrowser(); + return service; + } + + function search(): SearchParams; + function search(params: SearchParams): LocationService; + function search(key: string, value: SearchSetValue): LocationService; + function search(paramsOrKey?: SearchParams | string, value?: SearchSetValue): SearchParams | LocationService { + if (paramsOrKey === undefined) { + return copySearch(currentSearch); + } + if (typeof paramsOrKey === 'string') { + if (value === null || value === undefined) { + // Key removal — rebuild without the key instead of `delete` on a + // computed key (clarity + avoids the dynamic-delete lint). + const next: SearchParams = {}; + for (const [existingKey, existingValue] of Object.entries(currentSearch)) { + if (existingKey !== paramsOrKey) { + next[existingKey] = existingValue; + } + } + currentSearch = next; + } else { + currentSearch[paramsOrKey] = normalizeSearchValue(value); + } + } else { + // Whole-map replacement — a defensive copy so later caller-side + // mutations of the passed object don't leak into the service. + currentSearch = copySearch(paramsOrKey); + } + $$writeToBrowser(); + return service; + } + + function hash(): string; + function hash(value: string): LocationService; + function hash(value?: string): string | LocationService { + if (value === undefined) { + return currentHash; + } + currentHash = value; + $$writeToBrowser(); + return service; + } + + function url(): string; + function url(value: string): LocationService; + function url(value?: string): string | LocationService { + if (value === undefined) { + return composeAppUrl(currentTriple()); + } + const parsed = parseAppUrl(value); + currentPath = parsed.path; + currentSearch = parsed.search; + currentHash = parsed.hash; + $$writeToBrowser(); + return service; + } + + const service: LocationService = { path, search, hash, url }; + return service; +} diff --git a/tsconfig.json b/tsconfig.json index 848efa2..d453b9b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "@async/*": ["./src/async/*"], "@cache/*": ["./src/cache/*"], "@http/*": ["./src/http/*"], - "@forms/*": ["./src/forms/*"] + "@forms/*": ["./src/forms/*"], + "@location/*": ["./src/location/*"] } }, "include": ["src"], diff --git a/vitest.config.ts b/vitest.config.ts index 8d76406..bf8f975 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ '@cache': path.resolve(__dirname, 'src/cache'), '@http': path.resolve(__dirname, 'src/http'), '@forms': path.resolve(__dirname, 'src/forms'), + '@location': path.resolve(__dirname, 'src/location'), }, }, test: { From 758907bc2927fe4abeb3ee09a9fd12779af7efb1 Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Mon, 6 Jul 2026 16:26:23 -0400 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20$location=20full=20parity=20?= =?UTF-8?q?=E2=80=94=20HTML5=20mode,=20absUrl/protocol/host/port/state,=20?= =?UTF-8?q?replace(),=20$locationChange*=20events=20(spec=20040=20slice=20?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- context/spec/040-routing/tasks.md | 8 +- src/core/ng-module.ts | 23 +- src/index.ts | 9 + src/location/__tests__/location-di.test.ts | 16 +- .../__tests__/location-events.test.ts | 580 +++++++++++++++++ src/location/__tests__/location-html5.test.ts | 598 ++++++++++++++++++ src/location/index.ts | 24 +- src/location/location-provider.ts | 220 ++++++- src/location/location-types.ts | 155 ++++- src/location/location-url.ts | 98 +++ src/location/location.ts | 335 ++++++++-- 11 files changed, 1950 insertions(+), 116 deletions(-) create mode 100644 src/location/__tests__/location-events.test.ts create mode 100644 src/location/__tests__/location-html5.test.ts diff --git a/context/spec/040-routing/tasks.md b/context/spec/040-routing/tasks.md index d6570c0..fe4a5d9 100644 --- a/context/spec/040-routing/tasks.md +++ b/context/spec/040-routing/tasks.md @@ -17,10 +17,10 @@ Each slice keeps the library in a runnable, green state (`pnpm typecheck && pnpm ## Slice 2: `$location` full parity (HTML5 mode, remaining surface, change events) -- [ ] Extend `location-url.ts` + `location.ts`: `absUrl`/`protocol`/`host`/`port`/`state`, `replace()`, HTML5 composition. **[Agent: typescript-framework]** -- [ ] `$LocationProvider.html5Mode()` / `hashPrefix()` config getter/setters (spec-034 idiom, frozen at `$get`). **[Agent: typescript-framework]** -- [ ] Digest sync + `$locationChangeStart` (cancelable) / `$locationChangeSuccess` broadcast on `$rootScope`; guarded `$apply` → `invokeExceptionHandler`. **[Agent: typescript-framework]** -- [ ] Verify: unit tests both modes, `replace()`, event broadcast + cancelation, seam-driven URL change; port AngularJS `$location` vectors. **[Agent: vitest-testing]** +- [x] Extend `location-url.ts` + `location.ts`: `absUrl`/`protocol`/`host`/`port`/`state`, `replace()`, HTML5 composition. **[Agent: typescript-framework]** +- [x] `$LocationProvider.html5Mode()` / `hashPrefix()` config getter/setters (spec-034 idiom, frozen at `$get`). **[Agent: typescript-framework]** +- [x] Digest sync + `$locationChangeStart` (cancelable) / `$locationChangeSuccess` broadcast on `$rootScope`; guarded `$apply` → `invokeExceptionHandler`. **[Agent: typescript-framework]** +- [x] Verify: unit tests both modes, `replace()`, event broadcast + cancelation, seam-driven URL change; port AngularJS `$location` vectors. **[Agent: vitest-testing]** ## Slice 3: `ngRoute` skeleton + route matching + `$routeParams` diff --git a/src/core/ng-module.ts b/src/core/ng-module.ts index 89248ab..096eac8 100644 --- a/src/core/ng-module.ts +++ b/src/core/ng-module.ts @@ -290,16 +290,19 @@ export const ngModule = createModule('ng', []) // '$httpBackend','$cacheFactory']` are declared up front (the interceptor / // caching slices use `$injector` / `$cacheFactory`). .provider<'$http', HttpService, $HttpProvider>('$http', $HttpProvider) - // `$location` (spec 040 Slice 1) — the browser URL service, hashbang mode. - // Registered as a `.provider(...)` so config blocks reach the config-phase - // `hashPrefix()` getter/setter (`config(['$locationProvider', …])`); `$get` - // (zero deps in Slice 1) builds the service via the pure `createLocation` - // factory, whose browser seams (`window.location` / `window.history`) - // default by feature detection — a windowless environment yields the - // documented in-memory service. Digest sync, `$locationChangeStart` / - // `$locationChangeSuccess`, `html5Mode()`, and the read-only browser-URL - // getters land in Slice 2 (`$get` grows `$rootScope` / - // `$exceptionHandler` deps then). + // `$location` (spec 040 Slices 1–2) — the browser URL service (hashbang + // default, `html5Mode()` opt-in). Registered as a `.provider(...)` so + // config blocks reach the config-phase `hashPrefix()` / `html5Mode()` + // getter/setters (`config(['$locationProvider', …])`); `$get` is + // `['$rootScope', '$exceptionHandler', factory]` — it builds the service + // via the pure `createLocation` factory (browser seams `window.location` / + // `window.history` / `window.addEventListener` default by feature + // detection; windowless → the documented in-memory service) and wires the + // digest sync: an app-driven mutation flushes to the browser once per + // digest through a `$rootScope.$watch`, a browser-driven `hashchange` / + // `popstate` runs a guarded `$apply`, and both directions broadcast the + // cancelable `$locationChangeStart` + `$locationChangeSuccess` pair. + // LAZY — apps that never inject `$location` install no watch/listeners. .provider<'$location', LocationService, $LocationProvider>('$location', $LocationProvider) .provider('$sceDelegate', $SceDelegateProvider) .provider('$sce', $SceProvider) diff --git a/src/index.ts b/src/index.ts index fc13df2..6d7be95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -235,21 +235,30 @@ export type { export { composeAppUrl, composeHashbangHash, + composeHtml5Url, createLocation, DEFAULT_HASH_PREFIX, + normalizeBasePath, normalizePath, parseAppUrl, parseHashbangUrl, + parseHtml5Url, parseSearchString, + parseServerUrl, serializeSearch, + STATE_REQUIRES_HTML5_MODE_MESSAGE, $LocationProvider, } from './location/index'; export type { + AddEventListenerRef, + BrowserUrlEvent, CreateLocationArgs, HistoryRef, + Html5ModeConfig, LocationRef, LocationService, ParsedAppUrl, + ParsedServerUrl, SearchParams, SearchSetValue, SearchValue, diff --git a/src/location/__tests__/location-di.test.ts b/src/location/__tests__/location-di.test.ts index 4a21440..77f5797 100644 --- a/src/location/__tests__/location-di.test.ts +++ b/src/location/__tests__/location-di.test.ts @@ -9,10 +9,12 @@ * * 1. **Resolution + singleton** — `injector.get('$location')` yields the * service; repeated gets return the SAME reference (lazy `$get`, cached). - * 2. **Real-browser seam** — `$get` is zero-dep and feature-detects jsdom's + * 2. **Real-browser seam** — `$get` feature-detects jsdom's * `window.location`, so construction parses the real page URL and setters - * write the real `window.location.hash` (jsdom implements fragment-only - * navigation, so no "Not implemented: navigation" throw). + * write the real `window.location.hash` ON THE NEXT DIGEST (Slice 2 moved + * the DI-wired write from synchronous to digest-scheduled — AngularJS + * parity; jsdom implements fragment-only navigation, so no + * "Not implemented: navigation" throw). * 3. **Config-phase `hashPrefix`** — a `config(['$locationProvider', …])` * block takes effect (`''` → plain `#/path` URLs); the value is FROZEN at * `$get` (later mutations are ignored); a non-string argument throws @@ -80,10 +82,14 @@ describe('$location DI — jsdom browser seam', () => { expect($location.search()).toEqual({ x: '1' }); }); - it('setters write the real window.location.hash with the leading "#!"', () => { + it('setters write the real window.location.hash with the leading "#!" on the next digest', () => { const injector = bootstrapInjector([]); const $location = injector.get('$location'); $location.path('/di').search('q', '1'); + // Slice 2: the DI-wired write is digest-scheduled (AngularJS parity) — + // the browser is untouched until a digest flushes the mutation. + expect(window.location.hash).toBe(''); + injector.get('$rootScope').$digest(); expect(window.location.hash).toBe('#!/di?q=1'); }); @@ -111,6 +117,7 @@ describe('$locationProvider — config-phase hashPrefix', () => { expect($location.path()).toBe('/cfg'); expect($location.search()).toEqual({ a: '1' }); $location.path('/next'); + injector.get('$rootScope').$digest(); // Slice 2: DI-wired writes flush on digest expect(window.location.hash).toBe('#/next?a=1'); }); @@ -148,6 +155,7 @@ describe('$locationProvider — config-phase hashPrefix', () => { } captured.hashPrefix('@@'); $location.path('/frozen'); + injector.get('$rootScope').$digest(); // Slice 2: DI-wired writes flush on digest expect(window.location.hash).toBe('#!/frozen'); }); diff --git a/src/location/__tests__/location-events.test.ts b/src/location/__tests__/location-events.test.ts new file mode 100644 index 0000000..3570fb4 --- /dev/null +++ b/src/location/__tests__/location-events.test.ts @@ -0,0 +1,580 @@ +/** + * Tests for the spec 040 Slice 2 digest sync + `$locationChangeStart` / + * `$locationChangeSuccess` ceremony. + * + * Two layers: + * + * 1. **Manually-wired fine-grained tests** — `$LocationProvider.$get`'s + * wiring is inline (not separately importable) and constructs the service + * over the REAL browser seams, so fake-seam tests replicate the wiring + * VERBATIM here ({@link wireLocation} + {@link dispatchGuarded} — kept + * byte-equivalent to `location-provider.ts`; the DI layer below guards + * the real wiring against drift): `createLocation({ fake seams })` + a + * real `Scope.create()` root scope. Covers the initial-fire pair, the + * app-driven Start → write → Success ordering, cancel semantics on both + * the app-driven (revert, no write) and browser-driven (revert + FORCED + * history replace) paths, manual invocation of the captured `hashchange` + * / `popstate` seam listeners (never real jsdom events), `replace()` + * through a digest, and `$exceptionHandler` routing for throwing + * listeners. + * + * 2. **DI-wired coarse tests** — the REAL provider through + * `bootstrapInjector` over jsdom's real `window.location` (fragment-only + * navigation, so no jsdom "Not implemented" throw), pinning that the + * genuine `$get` wiring produces the same ceremony end-to-end (incl. an + * HTML5-mode `pushState` write against jsdom's real History API). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { bootstrapInjector } from '@bootstrap/index'; +import { Scope, type ScopeEvent } from '@core/index'; +import { createModule, resetRegistry } from '@di/module'; +import { invokeExceptionHandler, type ExceptionHandler } from '@exception-handler/index'; +import { createLocation, type BrowserUrlEvent, type LocationRef } from '@location/location'; +import type { $LocationProvider } from '@location/location-provider'; +import type { LocationService } from '@location/location-types'; + +// ──────────────────────────────────────────────────────────────────────────── +// wiring replica (see the file header — kept byte-equivalent to +// `$LocationProvider.$get` in location-provider.ts) +// ──────────────────────────────────────────────────────────────────────────── + +/** Replica of the provider-local `dispatchGuarded` ($$phase-guarded apply). */ +function dispatchGuarded(scope: Scope, exceptionHandler: ExceptionHandler, run: () => void): void { + try { + if (scope.$$phase !== null) { + scope.$evalAsync(run); + } else { + scope.$apply(run); + } + } catch (err) { + invokeExceptionHandler(exceptionHandler, err, 'eventListener'); + } +} + +/** Replica of the digest wiring `$LocationProvider.$get` installs. */ +function wireLocation($location: LocationService, $rootScope: Scope, $exceptionHandler: ExceptionHandler): void { + $location.$$setDigestWired(); + + // ── Browser → app (hashchange / popstate) ────────────────────────────── + $location.$$onUrlChange((href, state) => { + dispatchGuarded($rootScope, $exceptionHandler, () => { + const oldUrl = $location.$$committedAbsUrl(); + const oldState = $location.$$committedState(); + $location.$$parseBrowserUrl(href, state); + const newUrl = $location.absUrl(); + const newState = $location.state(); + if (newUrl === oldUrl && newState === oldState) { + return; // echo of our own write (or a no-op event) — nothing to do + } + const startEvent = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl, newState, oldState); + if (startEvent.defaultPrevented) { + $location.$$revert(); + $location.$$writeToBrowser(true); + } else { + $location.$$commit(); + $rootScope.$broadcast('$locationChangeSuccess', newUrl, oldUrl, newState, oldState); + } + }); + }); + + // ── App → browser (per-digest flush) ─────────────────────────────────── + let initializing = true; + $rootScope.$watch(() => { + const oldUrl = $location.$$committedAbsUrl(); + const oldState = $location.$$committedState(); + const newUrl = $location.absUrl(); + const newState = $location.state(); + const changed = newUrl !== oldUrl || newState !== oldState; + if (initializing || changed) { + initializing = false; + const startEvent = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl, newState, oldState); + if (startEvent.defaultPrevented) { + $location.$$revert(); + } else { + if (changed) { + $location.$$writeToBrowser(); + } + $location.$$commit(); + $rootScope.$broadcast('$locationChangeSuccess', newUrl, oldUrl, newState, oldState); + } + } + return undefined; + }); +} + +// ──────────────────────────────────────────────────────────────────────────── +// harness +// ──────────────────────────────────────────────────────────────────────────── + +interface FakeHistory { + pushState: ReturnType void>>; + replaceState: ReturnType void>>; + state: unknown; +} + +interface WiredHarness { + $location: LocationService; + rootScope: Scope; + ref: LocationRef; + history: FakeHistory; + replaceSpy: ReturnType void>>; + handler: ReturnType>; + listeners: Partial void>>; +} + +interface BootWiredOptions { + html5?: boolean; + basePath?: string; + historyState?: unknown; + /** Attach a `locationRef.replace` spy seam (hashbang replace path). */ + withLocationReplace?: boolean; +} + +/** Boot a DIGEST-WIRED service over fake seams (the manual wiring replica). */ +function bootWired(href: string, options: BootWiredOptions = {}): WiredHarness { + const listeners: WiredHarness['listeners'] = {}; + const replaceSpy = vi.fn<(url: string) => void>(); + const ref: LocationRef = { href, hash: '' }; + if (options.withLocationReplace === true) { + ref.replace = replaceSpy; + } + const history: FakeHistory = { + pushState: vi.fn<(data: unknown, title: string, url?: string | null) => void>(), + replaceState: vi.fn<(data: unknown, title: string, url?: string | null) => void>(), + state: options.historyState, + }; + const handler = vi.fn(); + const rootScope = Scope.create({ exceptionHandler: handler }); + const $location = createLocation({ + locationRef: ref, + historyRef: history, + addEventListenerRef: (type, listener) => { + listeners[type] = listener; + }, + html5Mode: options.html5 ?? false, + basePath: options.basePath, + }); + wireLocation($location, rootScope, handler); + return { $location, rootScope, ref, history, replaceSpy, handler, listeners }; +} + +interface CapturedEvent { + event: ScopeEvent; + args: unknown[]; +} + +interface EventLog { + starts: CapturedEvent[]; + successes: CapturedEvent[]; +} + +/** Record every `$locationChangeStart` / `$locationChangeSuccess` broadcast. */ +function captureEvents(rootScope: Scope): EventLog { + const log: EventLog = { starts: [], successes: [] }; + rootScope.$on('$locationChangeStart', (event, ...args) => { + log.starts.push({ event, args }); + }); + rootScope.$on('$locationChangeSuccess', (event, ...args) => { + log.successes.push({ event, args }); + }); + return log; +} + +/** Invoke a captured seam listener, throwing loudly if it never registered. */ +function fireBrowserEvent(harness: WiredHarness, type: 'hashchange' | 'popstate', event: BrowserUrlEvent): void { + const listener = harness.listeners[type]; + if (listener === undefined) { + throw new Error(`no ${type} listener was registered through the seam`); + } + listener(event); +} + +// ──────────────────────────────────────────────────────────────────────────── +// initial fire +// ──────────────────────────────────────────────────────────────────────────── + +describe('digest sync — initial fire', () => { + it('the FIRST digest broadcasts Start + Success once each with newUrl === oldUrl', () => { + const harness = bootWired('http://host/#!/initial?q=1'); + const log = captureEvents(harness.rootScope); + harness.rootScope.$digest(); + expect(log.starts).toHaveLength(1); + expect(log.successes).toHaveLength(1); + expect(log.starts[0]?.args).toEqual([ + 'http://host/#!/initial?q=1', + 'http://host/#!/initial?q=1', + undefined, + undefined, + ]); + expect(log.successes[0]?.args).toEqual(log.starts[0]?.args); + }); + + it('the initial fire performs NO browser write', () => { + const harness = bootWired('http://host/#!/initial'); + harness.rootScope.$digest(); + expect(harness.ref.hash).toBe(''); + expect(harness.replaceSpy).not.toHaveBeenCalled(); + }); + + it('a second no-change digest broadcasts nothing further', () => { + const harness = bootWired('http://host/#!/initial'); + const log = captureEvents(harness.rootScope); + harness.rootScope.$digest(); + harness.rootScope.$digest(); + expect(log.starts).toHaveLength(1); + expect(log.successes).toHaveLength(1); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// app-driven changes +// ──────────────────────────────────────────────────────────────────────────── + +describe('digest sync — app-driven changes', () => { + it('setters do NOT write synchronously once digest-wired', () => { + const harness = bootWired('http://host/#!/old'); + harness.rootScope.$digest(); // clear the initial fire + harness.$location.path('/next'); + expect(harness.ref.hash).toBe(''); + expect(harness.$location.$$committedAbsUrl()).toBe('http://host/#!/old'); + }); + + it('the next digest broadcasts Start(newUrl, oldUrl), writes the browser, then Success', () => { + const harness = bootWired('http://host/#!/old'); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + const hashAtStart: string[] = []; + const hashAtSuccess: string[] = []; + harness.rootScope.$on('$locationChangeStart', () => { + hashAtStart.push(harness.ref.hash); + }); + harness.rootScope.$on('$locationChangeSuccess', () => { + hashAtSuccess.push(harness.ref.hash); + }); + harness.$location.path('/next').search('q', '1'); + harness.rootScope.$digest(); + expect(log.starts).toHaveLength(1); + expect(log.successes).toHaveLength(1); + expect(log.starts[0]?.args.slice(0, 2)).toEqual(['http://host/#!/next?q=1', 'http://host/#!/old']); + expect(log.successes[0]?.args.slice(0, 2)).toEqual(['http://host/#!/next?q=1', 'http://host/#!/old']); + // Ordering: the browser is untouched DURING Start, written by Success. + expect(hashAtStart).toEqual(['']); + expect(hashAtSuccess).toEqual(['#!/next?q=1']); + expect(harness.$location.$$committedAbsUrl()).toBe('http://host/#!/next?q=1'); + }); + + it('multiple mutations before one digest coalesce into a SINGLE Start/Success pair', () => { + const harness = bootWired('http://host/#!/old'); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + harness.$location.path('/a'); + harness.$location.path('/b'); + harness.$location.hash('frag'); + harness.rootScope.$digest(); + expect(log.starts).toHaveLength(1); + expect(log.starts[0]?.args[0]).toBe('http://host/#!/b#frag'); + expect(harness.ref.hash).toBe('#!/b#frag'); + }); + + it('preventDefault() on Start reverts the internal state — no write, no Success', () => { + const harness = bootWired('http://host/#!/old'); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + harness.rootScope.$on('$locationChangeStart', (event) => { + event.preventDefault(); + }); + harness.$location.path('/vetoed'); + harness.rootScope.$digest(); + expect(log.starts).toHaveLength(1); + expect(log.successes).toHaveLength(0); + expect(harness.ref.hash).toBe(''); // browser untouched + expect(harness.$location.path()).toBe('/old'); // internal state reverted + // The reverted state is stable — a later digest broadcasts nothing new. + harness.rootScope.$digest(); + expect(log.starts).toHaveLength(1); + }); + + it('an HTML5-mode state() change alone triggers the ceremony and pushes the new state', () => { + const harness = bootWired('http://host/here', { html5: true }); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + harness.$location.state({ page: 2 }); + harness.rootScope.$digest(); + expect(log.starts).toHaveLength(1); + // args: [newUrl, oldUrl, newState, oldState] + expect(log.starts[0]?.args[2]).toEqual({ page: 2 }); + expect(log.starts[0]?.args[3]).toBeUndefined(); + expect(harness.history.pushState).toHaveBeenCalledTimes(1); + expect(harness.history.pushState).toHaveBeenCalledWith({ page: 2 }, '', 'http://host/here'); + expect(harness.$location.$$committedState()).toEqual({ page: 2 }); + }); + + it('replace() through a digest uses locationRef.replace in hashbang mode, then auto-clears', () => { + const harness = bootWired('http://host/#!/old', { withLocationReplace: true }); + harness.rootScope.$digest(); + harness.$location.replace().path('/replaced'); + harness.rootScope.$digest(); + expect(harness.replaceSpy).toHaveBeenCalledTimes(1); + expect(harness.replaceSpy).toHaveBeenCalledWith('http://host/#!/replaced'); + expect(harness.ref.hash).toBe(''); + // Auto-clear: the next digest-flushed write is a plain hash assignment. + harness.$location.path('/pushed'); + harness.rootScope.$digest(); + expect(harness.replaceSpy).toHaveBeenCalledTimes(1); + expect(harness.ref.hash).toBe('#!/pushed'); + }); + + it('replace() through a digest uses history.replaceState in HTML5 mode', () => { + const harness = bootWired('http://host/old', { html5: true }); + harness.rootScope.$digest(); + harness.$location.replace().path('/replaced'); + harness.rootScope.$digest(); + expect(harness.history.replaceState).toHaveBeenCalledWith(undefined, '', 'http://host/replaced'); + expect(harness.history.pushState).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// browser-driven changes (manual seam-listener invocation) +// ──────────────────────────────────────────────────────────────────────────── + +describe('digest sync — browser-driven changes', () => { + it('a hashchange re-parses the LIVE href and broadcasts Start then Success inside $apply', () => { + const harness = bootWired('http://host/#!/old'); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + const applyWitness = vi.fn(() => undefined); + harness.rootScope.$watch(applyWitness); + const watchCallsBefore = applyWitness.mock.calls.length; + harness.ref.href = 'http://host/#!/incoming?q=2'; + fireBrowserEvent(harness, 'hashchange', {}); + expect(log.starts).toHaveLength(1); + expect(log.successes).toHaveLength(1); + expect(log.starts[0]?.args.slice(0, 2)).toEqual(['http://host/#!/incoming?q=2', 'http://host/#!/old']); + expect(harness.$location.path()).toBe('/incoming'); + expect(harness.$location.search()).toEqual({ q: '2' }); + expect(harness.$location.$$committedAbsUrl()).toBe('http://host/#!/incoming?q=2'); + // $apply-wrapped: a digest ran during the listener invocation. + expect(applyWitness.mock.calls.length).toBeGreaterThan(watchCallsBefore); + expect(harness.rootScope.$$phase).toBeNull(); + }); + + it('an echo of our own committed URL broadcasts nothing', () => { + const harness = bootWired('http://host/#!/here'); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + harness.ref.href = harness.$location.$$committedAbsUrl(); + fireBrowserEvent(harness, 'hashchange', {}); + expect(log.starts).toHaveLength(0); + expect(log.successes).toHaveLength(0); + }); + + it('a hashchange PRESERVES the current state value (HTML5 mode)', () => { + const harness = bootWired('http://host/old', { html5: true, historyState: 'seed' }); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + harness.ref.href = 'http://host/incoming'; + fireBrowserEvent(harness, 'hashchange', {}); + expect(harness.$location.state()).toBe('seed'); + expect(log.starts[0]?.args[2]).toBe('seed'); // newState + expect(log.starts[0]?.args[3]).toBe('seed'); // oldState + }); + + it('a popstate delivers its event state into state() and the event args', () => { + const harness = bootWired('http://host/old', { html5: true }); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + harness.ref.href = 'http://host/back'; + fireBrowserEvent(harness, 'popstate', { state: { page: 2 } }); + expect(harness.$location.state()).toEqual({ page: 2 }); + expect(log.successes[0]?.args[2]).toEqual({ page: 2 }); + expect(harness.$location.$$committedState()).toEqual({ page: 2 }); + }); + + it('a canceled browser-driven change reverts + FORCE-replaces the old URL back (replace seam)', () => { + const harness = bootWired('http://host/#!/old', { withLocationReplace: true }); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + harness.rootScope.$on('$locationChangeStart', (event) => { + event.preventDefault(); + }); + harness.ref.href = 'http://host/#!/rejected'; + fireBrowserEvent(harness, 'hashchange', {}); + expect(log.starts).toHaveLength(1); + expect(log.successes).toHaveLength(0); + expect(harness.$location.path()).toBe('/old'); // internal state reverted + // Forced history REPLACE writing the OLD URL back to the browser. + expect(harness.replaceSpy).toHaveBeenCalledTimes(1); + expect(harness.replaceSpy).toHaveBeenCalledWith('http://host/#!/old'); + }); + + it('a canceled browser-driven change without a replace seam degrades to a hash assignment', () => { + const harness = bootWired('http://host/#!/old'); + harness.rootScope.$digest(); + harness.rootScope.$on('$locationChangeStart', (event) => { + event.preventDefault(); + }); + harness.ref.href = 'http://host/#!/rejected'; + fireBrowserEvent(harness, 'hashchange', {}); + expect(harness.ref.hash).toBe('#!/old'); + }); + + it('a canceled browser-driven change in HTML5 mode force-replaces via history.replaceState', () => { + const harness = bootWired('http://host/old', { html5: true }); + harness.rootScope.$digest(); + harness.rootScope.$on('$locationChangeStart', (event) => { + event.preventDefault(); + }); + harness.ref.href = 'http://host/rejected'; + fireBrowserEvent(harness, 'popstate', { state: 'rejected-state' }); + expect(harness.$location.path()).toBe('/old'); + expect(harness.$location.state()).toBeUndefined(); // reverted with the URL + expect(harness.history.replaceState).toHaveBeenCalledWith(undefined, '', 'http://host/old'); + expect(harness.history.pushState).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// $exceptionHandler routing for throwing listeners +// ──────────────────────────────────────────────────────────────────────────── + +describe('digest sync — throwing $locationChangeStart listeners', () => { + it("a throw on the BROWSER-driven path routes via $exceptionHandler ('eventListener') and the change still lands", () => { + const harness = bootWired('http://host/#!/old'); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + const boom = new Error('listener boom'); + harness.rootScope.$on('$locationChangeStart', () => { + throw boom; + }); + harness.ref.href = 'http://host/#!/incoming'; + fireBrowserEvent(harness, 'hashchange', {}); + expect(harness.handler).toHaveBeenCalledWith(boom, 'eventListener'); + // The throw is NOT a cancel — the ceremony completes. + expect(log.successes).toHaveLength(1); + expect(harness.$location.path()).toBe('/incoming'); + }); + + it("a throw on the APP-driven path routes via $exceptionHandler ('eventListener') and the digest continues", () => { + const harness = bootWired('http://host/#!/old'); + harness.rootScope.$digest(); + const log = captureEvents(harness.rootScope); + const boom = new Error('digest boom'); + const dereg = harness.rootScope.$on('$locationChangeStart', () => { + throw boom; + }); + harness.$location.path('/next'); + expect(() => { + harness.rootScope.$digest(); + }).not.toThrow(); + expect(harness.handler).toHaveBeenCalledWith(boom, 'eventListener'); + expect(log.successes).toHaveLength(1); + expect(harness.ref.hash).toBe('#!/next'); + // Digest keeps working afterwards. + dereg(); + harness.$location.path('/after'); + harness.rootScope.$digest(); + expect(harness.ref.hash).toBe('#!/after'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// DI-wired coarse tests (the REAL provider wiring over jsdom) +// ──────────────────────────────────────────────────────────────────────────── + +describe('digest sync — DI-wired (real provider, jsdom window)', () => { + afterEach(() => { + resetRegistry(); + window.location.hash = ''; + // Restore the pathname the HTML5-mode test may have pushed. + window.history.replaceState(null, '', '/'); + }); + + it('the first digest broadcasts the initial pair with newUrl === oldUrl and no browser write', () => { + const injector = bootstrapInjector([]); + injector.get('$location'); + const rootScope = injector.get('$rootScope'); + const log = captureEvents(rootScope); + rootScope.$digest(); + expect(log.starts).toHaveLength(1); + expect(log.successes).toHaveLength(1); + expect(log.starts[0]?.args[0]).toBe(log.starts[0]?.args[1]); + expect(window.location.hash).toBe(''); + }); + + it('an app-driven change flows Start → window.location.hash write → Success on the next digest', () => { + const injector = bootstrapInjector([]); + const $location = injector.get('$location'); + const rootScope = injector.get('$rootScope'); + rootScope.$digest(); // clear the initial fire + const log = captureEvents(rootScope); + $location.path('/e2e').search('q', '1'); + expect(window.location.hash).toBe(''); + rootScope.$digest(); + expect(window.location.hash).toBe('#!/e2e?q=1'); + expect(log.starts).toHaveLength(1); + expect(log.successes).toHaveLength(1); + expect(String(log.starts[0]?.args[0])).toContain('#!/e2e?q=1'); + }); + + it('preventDefault() on Start leaves the real browser untouched and reverts the service', () => { + const injector = bootstrapInjector([]); + const $location = injector.get('$location'); + const rootScope = injector.get('$rootScope'); + rootScope.$digest(); + const oldPath = $location.path(); + rootScope.$on('$locationChangeStart', (event) => { + event.preventDefault(); + }); + $location.path('/vetoed'); + rootScope.$digest(); + expect(window.location.hash).toBe(''); + expect($location.path()).toBe(oldPath); + }); + + it('HTML5 mode via a config block pushes the composed URL through jsdom’s real History API', () => { + createModule('app', []).config([ + '$locationProvider', + (p: $LocationProvider) => { + p.html5Mode(true); + }, + ]); + const injector = bootstrapInjector(['app']); + const $location = injector.get('$location'); + const rootScope = injector.get('$rootScope'); + rootScope.$digest(); + $location.path('/di-html5').search('q', '1'); + rootScope.$digest(); + expect(window.location.pathname).toBe('/di-html5'); + expect(window.location.search).toBe('?q=1'); + expect($location.absUrl()).toBe(`${window.location.origin}/di-html5?q=1`); + }); + + it('a throwing Start listener routes via the DEFAULT handler (console.error) and the write still lands', () => { + // The DI root scope uses the default `consoleErrorExceptionHandler`, so + // spying `console.error` is the observable proxy for the routing (the + // forms select.test.ts precedent). + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const injector = bootstrapInjector([]); + const $location = injector.get('$location'); + const rootScope = injector.get('$rootScope'); + rootScope.$digest(); + const log = captureEvents(rootScope); + rootScope.$on('$locationChangeStart', () => { + throw new Error('di listener boom'); + }); + $location.path('/di-boom'); + expect(() => { + rootScope.$digest(); + }).not.toThrow(); + expect(consoleSpy).toHaveBeenCalled(); + expect(log.successes).toHaveLength(1); + expect(window.location.hash).toBe('#!/di-boom'); + } finally { + consoleSpy.mockRestore(); + } + }); +}); diff --git a/src/location/__tests__/location-html5.test.ts b/src/location/__tests__/location-html5.test.ts new file mode 100644 index 0000000..c776fd1 --- /dev/null +++ b/src/location/__tests__/location-html5.test.ts @@ -0,0 +1,598 @@ +/** + * Tests for the spec 040 Slice 2 `$location` read surface + HTML5 mode + * (FS R4). + * + * All factory tests run STANDALONE over plain fake seams (`LocationRef` / + * `HistoryRef` objects — the `createHttpBackend` precedent), so the suite + * never touches jsdom's real `window.location` / `window.history`: + * + * 1. **Pure HTML5/server helpers** — `parseServerUrl`, `normalizeBasePath`, + * `parseHtml5Url`, `composeHtml5Url` edge cases. + * 2. **Read surface** — `absUrl()` composition in BOTH modes (+ in-memory), + * `protocol()` / `host()` / `port()` parsing (explicit port, per-scheme + * defaults, in-memory defaults). + * 3. **`state()`** — getter/setter parity incl. the hashbang setter throw + * (exact exported {@link STATE_REQUIRES_HTML5_MODE_MESSAGE}) and the + * construction-time `historyRef.state` seed (read ONCE). + * 4. **HTML5 writes (standalone = synchronous)** — initial parse under a + * base path, `pushState` with the composed URL + state value, `replace()` + * → `replaceState` + flag auto-clear, foreign-URL → empty app URL. + * 5. **Hashbang `replace()`** — the optional `locationRef.replace(fullUrl)` + * seam, the hash-assignment fallback, and flag auto-clear. + * 6. **Provider `html5Mode()`** — boolean/object forms, per-key `TypeError`, + * copy-on-get, chaining, frozen-at-`$get`, through `bootstrapInjector` + * config blocks. + * + * Assertion vectors ported from the AngularJS `locationSpec.js` HTML5-mode / + * server-URL basics where they fit the Slice 2 surface. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { bootstrapInjector } from '@bootstrap/index'; +import { createModule, resetRegistry } from '@di/module'; +import { createLocation, STATE_REQUIRES_HTML5_MODE_MESSAGE, type LocationRef } from '@location/location'; +import { $LocationProvider, type Html5ModeConfig } from '@location/location-provider'; +import type { LocationService } from '@location/location-types'; +import { composeHtml5Url, normalizeBasePath, parseHtml5Url, parseServerUrl } from '@location/location-url'; + +/** A fake `window.history` seam — spies satisfy `HistoryRef` structurally. */ +interface FakeHistory { + pushState: ReturnType void>>; + replaceState: ReturnType void>>; + state: unknown; +} + +function fakeHistory(initialState?: unknown): FakeHistory { + return { + pushState: vi.fn<(data: unknown, title: string, url?: string | null) => void>(), + replaceState: vi.fn<(data: unknown, title: string, url?: string | null) => void>(), + state: initialState, + }; +} + +/** Boot a STANDALONE HTML5-mode service over fake seams; return everything. */ +function bootHtml5( + href: string, + options: { basePath?: string; state?: unknown } = {}, +): { $location: LocationService; ref: LocationRef; history: FakeHistory } { + const ref: LocationRef = { href, hash: '' }; + const history = fakeHistory(options.state); + const $location = createLocation({ + html5Mode: true, + basePath: options.basePath, + locationRef: ref, + historyRef: history, + }); + return { $location, ref, history }; +} + +/** Boot a STANDALONE hashbang service over a fake seam (Slice 1 shape). */ +function bootHashbang(href: string): { $location: LocationService; ref: LocationRef } { + const ref: LocationRef = { href, hash: '' }; + return { $location: createLocation({ locationRef: ref }), ref }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// pure helpers — parseServerUrl +// ──────────────────────────────────────────────────────────────────────────── + +describe('parseServerUrl', () => { + it('parses protocol, host, explicit port, origin, and base from a full URL', () => { + const parsed = parseServerUrl('https://example.com:8080/base/index.html#!/users/42'); + expect(parsed.protocol).toBe('https'); + expect(parsed.host).toBe('example.com'); + expect(parsed.port).toBe(8080); + expect(parsed.origin).toBe('https://example.com:8080'); + expect(parsed.base).toBe('https://example.com:8080/base/index.html'); + }); + + it('defaults the port by scheme: http → 80, https → 443, ftp → 21', () => { + expect(parseServerUrl('http://h/').port).toBe(80); + expect(parseServerUrl('https://h/').port).toBe(443); + expect(parseServerUrl('ftp://h/').port).toBe(21); + }); + + it('falls back to port 80 for an unknown scheme', () => { + expect(parseServerUrl('ws://h/').port).toBe(80); + }); + + it('lowercases the protocol', () => { + expect(parseServerUrl('HTTP://h/').protocol).toBe('http'); + }); + + it('base is everything before the FIRST "#" (the hashbang absUrl base)', () => { + expect(parseServerUrl('http://h/app/#!/a#b').base).toBe('http://h/app/'); + }); + + it('base is the whole URL when there is no fragment', () => { + expect(parseServerUrl('http://h/app/').base).toBe('http://h/app/'); + }); + + it('an unparseable (relative) input yields the documented defaults, keeping base', () => { + const parsed = parseServerUrl('/just/a/path#frag'); + expect(parsed).toEqual({ protocol: 'http', host: '', port: 80, origin: '', base: '/just/a/path' }); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// pure helpers — normalizeBasePath +// ──────────────────────────────────────────────────────────────────────────── + +describe('normalizeBasePath', () => { + it('normalizes the root base "/" to the empty string', () => { + expect(normalizeBasePath('/')).toBe(''); + }); + + it('keeps a canonical "/app" unchanged', () => { + expect(normalizeBasePath('/app')).toBe('/app'); + }); + + it('strips a trailing slash: "/app/" → "/app"', () => { + expect(normalizeBasePath('/app/')).toBe('/app'); + }); + + it('strips EVERY trailing slash: "/app///" → "/app"', () => { + expect(normalizeBasePath('/app///')).toBe('/app'); + }); + + it('prepends a missing leading slash: "app" → "/app"', () => { + expect(normalizeBasePath('app')).toBe('/app'); + }); + + it('the empty string normalizes to the empty string', () => { + expect(normalizeBasePath('')).toBe(''); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// pure helpers — parseHtml5Url / composeHtml5Url +// ──────────────────────────────────────────────────────────────────────────── + +describe('parseHtml5Url', () => { + it('parses the app triple from a URL under origin + basePath', () => { + expect(parseHtml5Url('http://h/app/users/42?q=1#frag', 'http://h', '/app')).toEqual({ + path: '/users/42', + search: { q: '1' }, + hash: 'frag', + }); + }); + + it('parses under the root base "/" (prefix = origin only)', () => { + expect(parseHtml5Url('http://h/users?a=1', 'http://h', '/')).toEqual({ + path: '/users', + search: { a: '1' }, + hash: '', + }); + }); + + it('a FOREIGN origin yields the empty app URL', () => { + expect(parseHtml5Url('http://other/app/users', 'http://h', '/app')).toEqual({ path: '/', search: {}, hash: '' }); + }); + + it('a path OUTSIDE the base yields the empty app URL', () => { + expect(parseHtml5Url('http://h/elsewhere/users', 'http://h', '/app')).toEqual({ path: '/', search: {}, hash: '' }); + }); + + it('an empty prefix (in-memory: origin "" + base "/") parses the whole input as the app URL', () => { + expect(parseHtml5Url('/users?q=1', '', '/')).toEqual({ path: '/users', search: { q: '1' }, hash: '' }); + }); +}); + +describe('composeHtml5Url', () => { + it('composes origin + normalized base + app URL', () => { + expect(composeHtml5Url('http://h', '/app/', { path: '/users/42', search: { q: '1' }, hash: 'f' })).toBe( + 'http://h/app/users/42?q=1#f', + ); + }); + + it('the root base "/" contributes nothing', () => { + expect(composeHtml5Url('http://h', '/', { path: '/users', search: {}, hash: '' })).toBe('http://h/users'); + }); + + it('round-trips through parseHtml5Url', () => { + const triple = { path: '/a b/c', search: { 'k v': 'x&y', flag: true as const }, hash: 'fr ag' }; + const composed = composeHtml5Url('http://h', '/base', triple); + expect(parseHtml5Url(composed, 'http://h', '/base')).toEqual(triple); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// absUrl() +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — absUrl()', () => { + it('hashbang: base + "#!" + app URL, matching the initial href', () => { + const { $location } = bootHashbang('http://host/base/#!/users/42?q=1#frag'); + expect($location.absUrl()).toBe('http://host/base/#!/users/42?q=1#frag'); + }); + + it('hashbang: recomposes after a setter mutation', () => { + const { $location } = bootHashbang('http://host/base/#!/old'); + $location.path('/new').search('q', '1'); + expect($location.absUrl()).toBe('http://host/base/#!/new?q=1'); + }); + + it('hashbang with a custom empty prefix composes "#/path"', () => { + const ref: LocationRef = { href: 'http://host/#/plain', hash: '' }; + const $location = createLocation({ locationRef: ref, hashPrefix: '' }); + expect($location.absUrl()).toBe('http://host/#/plain'); + }); + + it('HTML5: origin + basePath + app URL', () => { + const { $location } = bootHtml5('http://host/app/users/42?q=1', { basePath: '/app' }); + expect($location.absUrl()).toBe('http://host/app/users/42?q=1'); + }); + + it('HTML5 with the root base "/": origin + app URL', () => { + const { $location } = bootHtml5('http://host/users?a=1'); + expect($location.absUrl()).toBe('http://host/users?a=1'); + }); + + it('in-memory hashbang (locationRef: null): just the hash portion', () => { + const $location = createLocation({ locationRef: null }); + expect($location.absUrl()).toBe('#!/'); + $location.path('/mem'); + expect($location.absUrl()).toBe('#!/mem'); + }); + + it('in-memory HTML5: just the app URL (origin is "")', () => { + const $location = createLocation({ locationRef: null, historyRef: null, html5Mode: true }); + expect($location.absUrl()).toBe('/'); + $location.path('/mem').search('q', '1'); + expect($location.absUrl()).toBe('/mem?q=1'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// protocol() / host() / port() +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — protocol/host/port', () => { + it('parses an explicit port and a port-less host', () => { + const { $location } = bootHashbang('https://example.com:8443/base/#!/a'); + expect($location.protocol()).toBe('https'); + expect($location.host()).toBe('example.com'); + expect($location.port()).toBe(8443); + }); + + it('defaults the port by scheme (http 80 / https 443 / ftp 21 / unknown 80)', () => { + expect(bootHashbang('http://h/#!/').$location.port()).toBe(80); + expect(bootHashbang('https://h/#!/').$location.port()).toBe(443); + expect(bootHashbang('ftp://h/#!/').$location.port()).toBe(21); + expect(bootHashbang('ws://h/#!/').$location.port()).toBe(80); + }); + + it('reports the in-memory defaults when locationRef is null', () => { + const $location = createLocation({ locationRef: null }); + expect($location.protocol()).toBe('http'); + expect($location.host()).toBe(''); + expect($location.port()).toBe(80); + }); + + it('reports the same defaults for an unparseable (relative) href', () => { + const { $location } = bootHashbang('/relative/#!/a'); + expect($location.protocol()).toBe('http'); + expect($location.host()).toBe(''); + expect($location.port()).toBe(80); + }); + + it('is parsed ONCE at construction — later href mutations are invisible', () => { + const { $location, ref } = bootHashbang('http://first/#!/a'); + ref.href = 'https://second:9999/#!/a'; + expect($location.protocol()).toBe('http'); + expect($location.host()).toBe('first'); + expect($location.port()).toBe(80); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// state() +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — state()', () => { + it('the getter returns undefined in hashbang mode', () => { + const { $location } = bootHashbang('http://host/#!/a'); + expect($location.state()).toBeUndefined(); + }); + + it('the SETTER throws in hashbang mode with the exact exported message', () => { + const { $location } = bootHashbang('http://host/#!/a'); + expect(() => $location.state({ page: 1 })).toThrow(new Error(STATE_REQUIRES_HTML5_MODE_MESSAGE)); + }); + + it('STATE_REQUIRES_HTML5_MODE_MESSAGE carries the AngularJS $location:nostate text', () => { + expect(STATE_REQUIRES_HTML5_MODE_MESSAGE).toBe( + 'History API state support is available only in HTML5 mode and only in browsers supporting HTML5 History API', + ); + }); + + it('HTML5: the setter is fluent and the getter reads the value back', () => { + const { $location } = bootHtml5('http://host/'); + const value = { page: 2 }; + expect($location.state(value)).toBe($location); + expect($location.state()).toBe(value); + }); + + it('HTML5: seeds from historyRef.state at construction', () => { + const { $location } = bootHtml5('http://host/', { state: { seeded: true } }); + expect($location.state()).toEqual({ seeded: true }); + }); + + it('HTML5: reads historyRef.state exactly ONCE — later mutations are invisible', () => { + const history = fakeHistory('initial'); + const $location = createLocation({ + html5Mode: true, + locationRef: { href: 'http://host/', hash: '' }, + historyRef: history, + }); + history.state = 'mutated'; + expect($location.state()).toBe('initial'); + }); + + it('HTML5 with a null historyRef: state starts undefined, setter still works (write is a no-op)', () => { + const $location = createLocation({ html5Mode: true, locationRef: null, historyRef: null }); + expect($location.state()).toBeUndefined(); + $location.state(42); + expect($location.state()).toBe(42); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// HTML5 mode — construction parse + standalone writes +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — HTML5 mode', () => { + it('parses path/search/hash from the initial href under the base path', () => { + const { $location } = bootHtml5('http://host/app/users/42?q=1#frag', { basePath: '/app' }); + expect($location.path()).toBe('/users/42'); + expect($location.search()).toEqual({ q: '1' }); + expect($location.hash()).toBe('frag'); + expect($location.url()).toBe('/users/42?q=1#frag'); + }); + + it('a trailing slash on basePath is tolerated (normalized)', () => { + const { $location } = bootHtml5('http://host/app/users', { basePath: '/app/' }); + expect($location.path()).toBe('/users'); + }); + + it('a browser-delivered FOREIGN-origin URL parses as the empty app URL', () => { + // The origin derives from the initial href itself, so foreignness can + // only arrive via a later browser-driven URL ($$parseBrowserUrl). + const { $location } = bootHtml5('http://host/app/users', { basePath: '/app' }); + $location.$$parseBrowserUrl('http://other/app/elsewhere'); + expect($location.path()).toBe('/'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + }); + + it('a same-origin href OUTSIDE the base path seeds the empty app URL', () => { + const { $location } = bootHtml5('http://host/elsewhere/users', { basePath: '/app' }); + expect($location.path()).toBe('/'); + }); + + it('standalone setters call pushState SYNCHRONOUSLY with the composed URL', () => { + const { $location, history } = bootHtml5('http://host/app/old', { basePath: '/app' }); + $location.path('/new'); + expect(history.pushState).toHaveBeenCalledTimes(1); + expect(history.pushState).toHaveBeenCalledWith(undefined, '', 'http://host/app/new'); + }); + + it('pushState carries the current state() value', () => { + const { $location, history } = bootHtml5('http://host/old'); + $location.state({ page: 3 }); + $location.path('/new'); + expect(history.pushState).toHaveBeenLastCalledWith({ page: 3 }, '', 'http://host/new'); + }); + + it('the state SETTER itself triggers a standalone write (pushState with the unchanged URL)', () => { + const { $location, history } = bootHtml5('http://host/here'); + $location.state('s1'); + expect(history.pushState).toHaveBeenCalledWith('s1', '', 'http://host/here'); + }); + + it('url(value) composes search and hash into the pushed URL', () => { + const { $location, history } = bootHtml5('http://host/app/', { basePath: '/app' }); + $location.url('/users?q=1#frag'); + expect(history.pushState).toHaveBeenCalledWith(undefined, '', 'http://host/app/users?q=1#frag'); + }); + + it('replace() routes the NEXT write through replaceState and auto-clears', () => { + const { $location, history } = bootHtml5('http://host/old'); + const returned = $location.replace(); + expect(returned).toBe($location); + $location.path('/replaced'); + expect(history.replaceState).toHaveBeenCalledWith(undefined, '', 'http://host/replaced'); + expect(history.pushState).not.toHaveBeenCalled(); + // Auto-clear: the write AFTER the replace-write is a push again. + $location.path('/pushed'); + expect(history.pushState).toHaveBeenCalledWith(undefined, '', 'http://host/pushed'); + expect(history.replaceState).toHaveBeenCalledTimes(1); + }); + + it('a null historyRef makes HTML5 writes a guarded no-op', () => { + const $location = createLocation({ + html5Mode: true, + locationRef: { href: 'http://host/a', hash: '' }, + historyRef: null, + }); + expect(() => $location.path('/silent')).not.toThrow(); + expect($location.path()).toBe('/silent'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// hashbang replace() +// ──────────────────────────────────────────────────────────────────────────── + +describe('createLocation — hashbang replace()', () => { + it('uses locationRef.replace(fullAbsUrl) when the seam provides it, and auto-clears', () => { + const replaceSpy = vi.fn<(url: string) => void>(); + const ref: LocationRef = { href: 'http://host/base/#!/old', hash: '', replace: replaceSpy }; + const $location = createLocation({ locationRef: ref }); + $location.replace().path('/replaced'); + expect(replaceSpy).toHaveBeenCalledTimes(1); + expect(replaceSpy).toHaveBeenCalledWith('http://host/base/#!/replaced'); + expect(ref.hash).toBe(''); // the replace path did NOT assign the hash + // Auto-clear: the next write is a plain hash assignment. + $location.path('/pushed'); + expect(ref.hash).toBe('#!/pushed'); + expect(replaceSpy).toHaveBeenCalledTimes(1); + }); + + it('degrades to a plain hash assignment when the seam has no replace()', () => { + const { $location, ref } = bootHashbang('http://host/#!/old'); + $location.replace().path('/replaced'); + expect(ref.hash).toBe('#!/replaced'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// provider — html5Mode() config surface +// ──────────────────────────────────────────────────────────────────────────── + +describe('$LocationProvider — html5Mode()', () => { + it('the getter defaults to { enabled: false, requireBase: true, rewriteLinks: true }', () => { + const provider = new $LocationProvider(); + expect(provider.html5Mode()).toEqual({ enabled: false, requireBase: true, rewriteLinks: true }); + }); + + it('the boolean form sets enabled only and returns the provider for chaining', () => { + const provider = new $LocationProvider(); + expect(provider.html5Mode(true)).toBe(provider); + expect(provider.html5Mode()).toEqual({ enabled: true, requireBase: true, rewriteLinks: true }); + }); + + it('the object form merges the recognized keys', () => { + const provider = new $LocationProvider(); + provider.html5Mode({ enabled: true, requireBase: false, rewriteLinks: false }); + expect(provider.html5Mode()).toEqual({ enabled: true, requireBase: false, rewriteLinks: false }); + }); + + it('a partial object leaves the other keys untouched', () => { + const provider = new $LocationProvider(); + provider.html5Mode({ requireBase: false }); + expect(provider.html5Mode()).toEqual({ enabled: false, requireBase: false, rewriteLinks: true }); + }); + + it('rewriteLinks accepts a string (the AngularJS attribute-name form)', () => { + const provider = new $LocationProvider(); + provider.html5Mode({ rewriteLinks: 'internal-link' }); + expect(provider.html5Mode().rewriteLinks).toBe('internal-link'); + }); + + it('unknown keys are ignored', () => { + const provider = new $LocationProvider(); + const withExtra = { enabled: true, extra: 'x' }; + provider.html5Mode(withExtra as Partial); + expect(provider.html5Mode()).toEqual({ enabled: true, requireBase: true, rewriteLinks: true }); + }); + + it('a non-boolean enabled throws a per-key TypeError', () => { + const provider = new $LocationProvider(); + expect(() => provider.html5Mode({ enabled: 'yes' as unknown as boolean })).toThrow( + new TypeError('html5Mode.enabled expects a boolean'), + ); + }); + + it('a non-boolean requireBase throws a per-key TypeError', () => { + const provider = new $LocationProvider(); + expect(() => provider.html5Mode({ requireBase: 1 as unknown as boolean })).toThrow( + new TypeError('html5Mode.requireBase expects a boolean'), + ); + }); + + it('a non-boolean/non-string rewriteLinks throws a per-key TypeError', () => { + const provider = new $LocationProvider(); + expect(() => provider.html5Mode({ rewriteLinks: 42 as unknown as boolean })).toThrow( + new TypeError('html5Mode.rewriteLinks expects a boolean or string'), + ); + }); + + it('a non-boolean/non-object argument throws', () => { + const provider = new $LocationProvider(); + expect(() => provider.html5Mode(42 as unknown as boolean)).toThrow( + new TypeError('html5Mode expects a boolean or object argument'), + ); + }); + + it('a failed per-key validation leaves earlier-validated keys of the SAME call applied (documented merge order)', () => { + const provider = new $LocationProvider(); + expect(() => provider.html5Mode({ enabled: true, requireBase: 1 as unknown as boolean })).toThrow(TypeError); + // `enabled` was validated + stored before `requireBase` threw. + expect(provider.html5Mode().enabled).toBe(true); + }); + + it('the getter returns a COPY — mutating it does not write back', () => { + const provider = new $LocationProvider(); + const snapshot = provider.html5Mode(); + snapshot.enabled = true; + expect(provider.html5Mode().enabled).toBe(false); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// provider — html5Mode() through bootstrapInjector config blocks +// ──────────────────────────────────────────────────────────────────────────── + +describe('$LocationProvider — html5Mode() via DI config blocks', () => { + afterEach(() => { + resetRegistry(); + window.location.hash = ''; + }); + + /** Register an `'app'` module whose config block receives the provider. */ + function registerConfigApp(configure: (p: $LocationProvider) => void): void { + createModule('app', []).config(['$locationProvider', configure]); + } + + it('html5Mode(true) in a config block produces an HTML5-mode service (state setter live)', () => { + registerConfigApp((p) => { + p.html5Mode(true); + }); + const injector = bootstrapInjector(['app']); + const $location = injector.get('$location'); + // The hashbang-mode setter would throw; HTML5 mode accepts state writes. + expect(() => $location.state({ ok: true })).not.toThrow(); + expect($location.state()).toEqual({ ok: true }); + }); + + it('the default (no config) service is hashbang — the state setter throws', () => { + const injector = bootstrapInjector([]); + const $location = injector.get('$location'); + expect(() => $location.state({ nope: true })).toThrow(new Error(STATE_REQUIRES_HTML5_MODE_MESSAGE)); + }); + + it('the getter form inside a config block reads the current config', () => { + let observed: Html5ModeConfig | null = null; + registerConfigApp((p) => { + p.html5Mode({ enabled: true, requireBase: false }); + observed = p.html5Mode(); + }); + bootstrapInjector(['app']); + expect(observed).toEqual({ enabled: true, requireBase: false, rewriteLinks: true }); + }); + + it('a per-key TypeError inside a config block surfaces synchronously from bootstrapInjector', () => { + registerConfigApp((p) => { + p.html5Mode({ enabled: 'x' as unknown as boolean }); + }); + expect(() => bootstrapInjector(['app'])).toThrow(new TypeError('html5Mode.enabled expects a boolean')); + }); + + it('the mode is FROZEN at $get — enabling html5Mode after the service exists is ignored', () => { + const holder: { provider: $LocationProvider | null } = { provider: null }; + registerConfigApp((p) => { + holder.provider = p; + }); + const injector = bootstrapInjector(['app']); + const $location = injector.get('$location'); // forces $get — hashbang baked in + const captured = holder.provider; + if (captured === null) { + throw new Error('config block did not run'); + } + captured.html5Mode(true); + // Still the hashbang service: the state setter throws. + expect(() => $location.state(1)).toThrow(new Error(STATE_REQUIRES_HTML5_MODE_MESSAGE)); + }); +}); diff --git a/src/location/index.ts b/src/location/index.ts index 7fb2ad1..ec82182 100644 --- a/src/location/index.ts +++ b/src/location/index.ts @@ -1,27 +1,33 @@ /** * Public barrel for the `@location` module — the browser URL service - * (`$location`, `$LocationProvider`; spec 040 Slice 1). + * (`$location`, `$LocationProvider`; spec 040 Slices 1–2). * * Exposes the pure {@link createLocation} factory (injectable browser seams * — the `createHttpBackend` precedent), the config-phase - * {@link $LocationProvider}, the pure hashbang URL parse/serialize helpers, - * and the public contract types. The DI registration lives on `ngModule` - * (`src/core/ng-module.ts`), not here — mirroring the `@async` / `@cache` / - * `@http` precedent where a service ships a pure factory AND a separate - * `ngModule` registration. + * {@link $LocationProvider} (`hashPrefix()` / `html5Mode()` + the digest + * sync + `$locationChange*` wiring in `$get`), the pure URL parse/serialize + * helpers (hashbang + HTML5 + server portion), and the public contract + * types. The DI registration lives on `ngModule` (`src/core/ng-module.ts`), + * not here — mirroring the `@async` / `@cache` / `@http` precedent where a + * service ships a pure factory AND a separate `ngModule` registration. */ -export { createLocation, DEFAULT_HASH_PREFIX } from './location'; -export type { CreateLocationArgs, HistoryRef, LocationRef } from './location'; +export { createLocation, DEFAULT_HASH_PREFIX, STATE_REQUIRES_HTML5_MODE_MESSAGE } from './location'; +export type { AddEventListenerRef, BrowserUrlEvent, CreateLocationArgs, HistoryRef, LocationRef } from './location'; export { $LocationProvider } from './location-provider'; +export type { Html5ModeConfig } from './location-provider'; export type { LocationService, SearchSetValue } from './location-types'; export { composeAppUrl, composeHashbangHash, + composeHtml5Url, + normalizeBasePath, normalizePath, parseAppUrl, parseHashbangUrl, + parseHtml5Url, parseSearchString, + parseServerUrl, serializeSearch, } from './location-url'; -export type { ParsedAppUrl, SearchParams, SearchValue } from './location-url'; +export type { ParsedAppUrl, ParsedServerUrl, SearchParams, SearchValue } from './location-url'; diff --git a/src/location/location-provider.ts b/src/location/location-provider.ts index 43ae831..9c5f315 100644 --- a/src/location/location-provider.ts +++ b/src/location/location-provider.ts @@ -1,36 +1,106 @@ /** * `$LocationProvider` — config-phase configurator for the `$location` - * service (spec 040 Slice 1). + * service (spec 040 Slices 1–2). * - * Slice 1 ships the `hashPrefix()` getter/setter (the spec-034 - * `$compileProvider` idiom — no-arg → get, with-arg → validate + store + - * return `this` for chaining) and a `$get` that builds the service via the - * pure {@link createLocation} factory with the REAL browser seams - * (feature-detected inside the factory — a windowless environment yields the - * documented in-memory service). `html5Mode()` and the digest-sync wiring - * (`$rootScope` dependency, `hashchange` listener, `$locationChange*` - * events) land in Slice 2 — `$get` grows its dependency array then. + * Config surface (both follow the spec-034 `$compileProvider` idiom — + * no-arg → get, with-arg → validate + store + return `this`; frozen at + * `$get`): * - * The stored prefix is read once at `$get` time, so a config-block mutation - * after the run phase begins has no effect (AngularJS parity — the frozen-at- - * `$get` contract every config-phase provider here follows). + * - `hashPrefix(string)` — the hashbang prefix, default `'!'`. + * - `html5Mode(boolean | { enabled?, requireBase?, rewriteLinks? })` — the + * URL style (FS R4). The object form is accepted for AngularJS shape + * parity but ONLY `enabled` is honored — `requireBase` / `rewriteLinks` + * are validated + stored + readable back, never acted on (documented + * deliberate simplification: no `` requirement is enforced and no + * link rewriting ships). + * + * `$get` is `['$rootScope', '$exceptionHandler', factory]` and wires the + * pure {@link createLocation} factory (real browser seams feature-detected + * inside it) into the digest: + * + * - **App → browser:** a `$rootScope.$watch` (the AngularJS + * `$locationWatch` mechanism — a side-effecting watch function with no + * listener) compares the working `absUrl()` / `state()` against the last + * committed snapshot each digest; on divergence it broadcasts + * `$locationChangeStart(newUrl, oldUrl, newState, oldState)` (cancelable + * — `preventDefault()` REVERTS the internal state, no browser write, no + * success event), else writes to the browser (push vs replace per the + * `replace()` flag), commits, and broadcasts `$locationChangeSuccess`. + * Unlike upstream the ceremony runs synchronously inside the watch + * function (no inner `$evalAsync` hop — documented simplification); a + * throw routes via the digest's standard `'watchFn'` catch path. + * - **Initial fire (upstream parity):** the FIRST watch pass broadcasts the + * `$locationChangeStart` / `$locationChangeSuccess` pair with + * `newUrl === oldUrl` and performs no browser write — this is what lets + * `$route` (later slices) resolve the initial route on the first digest. + * - **Browser → app:** the factory's seam-registered `hashchange` / + * `popstate` listeners funnel into the `$$onUrlChange` callback installed + * here, which re-parses the live browser URL and — only when it differs + * from the committed state — runs the same `$locationChangeStart` / + * `$locationChangeSuccess` ceremony inside a GUARDED `$rootScope.$apply` + * (`$$phase`-guarded + try/catch, replicating the compiler-internal + * `applyPhaseGuarded` helper locally — `@location` does not import + * `@compiler`; throws route via `$exceptionHandler` cause + * `'eventListener'`, the spec-026 native-event precedent). A + * `preventDefault()`ed start event REVERTS the browser URL to the old one + * via a forced history REPLACE (`$$writeToBrowser(true)`) so the rejected + * URL does not linger in the address bar or the history stack. * * @example * ```ts * createModule('app', ['ng']).config(['$locationProvider', (p) => { - * p.hashPrefix(''); // plain `#/path` URLs instead of `#!/path` + * p.hashPrefix(''); // plain `#/path` URLs instead of `#!/path` + * p.html5Mode({ enabled: true }); // clean URLs via the History API * }]); * ``` */ +import type { Scope } from '@core/index'; +import { invokeExceptionHandler, type ExceptionHandler } from '@exception-handler/index'; import { createLocation, DEFAULT_HASH_PREFIX } from './location'; import type { LocationService } from './location-types'; +/** + * The `html5Mode()` configuration object — AngularJS shape parity. Only + * `enabled` changes behavior; `requireBase` / `rewriteLinks` are stored and + * readable but deliberately NOT honored (see the file header). + */ +export interface Html5ModeConfig { + /** Clean-URL (History API) mode on/off. Default `false` (hashbang). */ + enabled: boolean; + /** Accepted for parity; NOT honored (no `` enforcement). Default `true`. */ + requireBase: boolean; + /** Accepted for parity; NOT honored (no link rewriting ships). Default `true`. */ + rewriteLinks: boolean | string; +} + +/** + * `$$phase`-guarded `$apply` / `$evalAsync` dispatch — a local replica of + * the compiler-internal `applyPhaseGuarded` helper (`@location` must not + * import `@compiler`). Mid-digest browser events queue via `$evalAsync` + * (whose throws drain through the digest's `'$evalAsync'` path); the common + * no-phase path runs `$apply`, and because core `$apply` is + * `try/finally`-only, the synchronous throw is caught HERE and routed via + * `$exceptionHandler` with cause `'eventListener'`. + */ +function dispatchGuarded(scope: Scope, exceptionHandler: ExceptionHandler, run: () => void): void { + try { + if (scope.$$phase !== null) { + scope.$evalAsync(run); + } else { + scope.$apply(run); + } + } catch (err) { + invokeExceptionHandler(exceptionHandler, err, 'eventListener'); + } +} + export class $LocationProvider { // `$$` prefix mirrors the AngularJS "internal / not part of the public // API" convention (`$InterpolateProvider.$$startSymbol` precedent). Kept - // private so callers are routed through the validated setter. + // private so callers are routed through the validated setters. private $$hashPrefix: string = DEFAULT_HASH_PREFIX; + private $$html5Mode: Html5ModeConfig = { enabled: false, requireBase: true, rewriteLinks: true }; /** * Config-phase getter/setter for the hashbang prefix (spec-034 idiom). @@ -56,10 +126,122 @@ export class $LocationProvider { } /** - * Injector-facing factory. Zero dependencies in Slice 1 — the browser - * seams default inside {@link createLocation} (feature-detected - * `window.location` / `window.history`). Slice 2 adds `$rootScope` (digest - * sync + `$locationChange*` broadcast) and `$exceptionHandler` here. + * Config-phase getter/setter for the URL style (spec-034 idiom; FS R4). + * + * - Called with NO argument → returns a COPY of the current + * {@link Html5ModeConfig} (mutating it does not write back). + * - Called with a BOOLEAN → sets `enabled` only. + * - Called with an OBJECT → merges the recognized keys after per-key type + * validation (`enabled` / `requireBase` booleans; `rewriteLinks` + * boolean or string); unknown keys are ignored. Only `enabled` changes + * behavior — see {@link Html5ModeConfig}. + * + * The value in force at `$get` time is baked into the produced service — + * later mutations are ignored. + */ + html5Mode(): Html5ModeConfig; + html5Mode(mode: boolean | Partial): this; + html5Mode(mode?: boolean | Partial): Html5ModeConfig | this { + if (mode === undefined) { + return { ...this.$$html5Mode }; + } + if (typeof mode === 'boolean') { + this.$$html5Mode.enabled = mode; + return this; + } + if (typeof mode !== 'object') { + throw new TypeError('html5Mode expects a boolean or object argument'); + } + if (mode.enabled !== undefined) { + if (typeof mode.enabled !== 'boolean') { + throw new TypeError('html5Mode.enabled expects a boolean'); + } + this.$$html5Mode.enabled = mode.enabled; + } + if (mode.requireBase !== undefined) { + if (typeof mode.requireBase !== 'boolean') { + throw new TypeError('html5Mode.requireBase expects a boolean'); + } + this.$$html5Mode.requireBase = mode.requireBase; + } + if (mode.rewriteLinks !== undefined) { + if (typeof mode.rewriteLinks !== 'boolean' && typeof mode.rewriteLinks !== 'string') { + throw new TypeError('html5Mode.rewriteLinks expects a boolean or string'); + } + this.$$html5Mode.rewriteLinks = mode.rewriteLinks; + } + return this; + } + + /** + * Injector-facing factory — builds the service via the pure + * {@link createLocation} factory (browser seams feature-detected inside + * it) and wires the digest sync + `$locationChange*` ceremony documented + * on the file header. */ - $get = [(): LocationService => createLocation({ hashPrefix: this.$$hashPrefix })] as const; + $get = [ + '$rootScope', + '$exceptionHandler', + ($rootScope: Scope, $exceptionHandler: ExceptionHandler): LocationService => { + const $location = createLocation({ + hashPrefix: this.$$hashPrefix, + html5Mode: this.$$html5Mode.enabled, + }); + // Setters stop writing synchronously — the watch below owns the flush. + $location.$$setDigestWired(); + + // ── Browser → app (hashchange / popstate) ────────────────────────── + $location.$$onUrlChange((href, state) => { + dispatchGuarded($rootScope, $exceptionHandler, () => { + const oldUrl = $location.$$committedAbsUrl(); + const oldState = $location.$$committedState(); + $location.$$parseBrowserUrl(href, state); + const newUrl = $location.absUrl(); + const newState = $location.state(); + if (newUrl === oldUrl && newState === oldState) { + return; // echo of our own write (or a no-op event) — nothing to do + } + const startEvent = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl, newState, oldState); + if (startEvent.defaultPrevented) { + // Listener vetoed the browser-driven change: restore the internal + // state and REVERT the browser URL to the old one (forced history + // REPLACE so the rejected URL leaves no history entry behind). + $location.$$revert(); + $location.$$writeToBrowser(true); + } else { + $location.$$commit(); + $rootScope.$broadcast('$locationChangeSuccess', newUrl, oldUrl, newState, oldState); + } + }); + }); + + // ── App → browser (per-digest flush; AngularJS `$locationWatch`) ─── + let initializing = true; + $rootScope.$watch(() => { + const oldUrl = $location.$$committedAbsUrl(); + const oldState = $location.$$committedState(); + const newUrl = $location.absUrl(); + const newState = $location.state(); + const changed = newUrl !== oldUrl || newState !== oldState; + if (initializing || changed) { + initializing = false; + const startEvent = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl, newState, oldState); + if (startEvent.defaultPrevented) { + // Vetoed app-driven change: revert the internal state — the + // browser was never touched, so there is nothing else to undo. + $location.$$revert(); + } else { + if (changed) { + $location.$$writeToBrowser(); + } + $location.$$commit(); + $rootScope.$broadcast('$locationChangeSuccess', newUrl, oldUrl, newState, oldState); + } + } + return undefined; + }); + + return $location; + }, + ] as const; } diff --git a/src/location/location-types.ts b/src/location/location-types.ts index 61dc8cf..6106786 100644 --- a/src/location/location-types.ts +++ b/src/location/location-types.ts @@ -1,17 +1,19 @@ /** - * Public contract types for the `$location` service (spec 040 Slice 1). + * Public contract types for the `$location` service (spec 040 Slices 1–2). * * {@link LocationService} is the run-phase surface apps program against — * the getter/setter pairs for the app-URL triple (`path` / `search` / - * `hash`) plus the composed `url` accessor. Slice 2 widens it with the - * read-only browser-URL getters (`absUrl` / `protocol` / `host` / `port` / - * `state`), `replace()`, and the digest-synced `$locationChangeStart` / - * `$locationChangeSuccess` events — the Slice 1 surface is designed so those - * bolt on additively. + * `hash`), the composed `url` accessor, the read-only browser-URL getters + * (`absUrl` / `protocol` / `host` / `port`), the HTML5-mode `state` + * getter/setter, and the fluent `replace()` marker — plus the `$$`-prefixed + * framework internals that `$LocationProvider.$get` wires into the digest + * (typed directly on the service, the `Scope.$$phase` precedent; app code + * MUST NOT call them). * - * The seam shapes (`LocationRef` / `HistoryRef` / `CreateLocationArgs`) live - * next to the factory in `src/location/location.ts`, mirroring how - * `CreateHttpBackendArgs` lives in `src/http/http-backend.ts`. + * The seam shapes (`LocationRef` / `HistoryRef` / `AddEventListenerRef` / + * `CreateLocationArgs`) live next to the factory in + * `src/location/location.ts`, mirroring how `CreateHttpBackendArgs` lives in + * `src/http/http-backend.ts`. */ import type { SearchParams } from './location-url'; @@ -88,4 +90,139 @@ export interface LocationService { * their empty defaults (`url('/plain')` clears search and hash). */ url(value: string): LocationService; + + /** + * Get the FULL browser URL for the current internal state. + * + * - Hashbang mode: the construction-time base (everything before the + * first `#` of the initial `href`) + `#` — + * `'http://host/base/#!/users/42?q=1'`. In-memory mode the base is + * `''`, so `absUrl()` is just the hash portion (`'#!/users/42'`). + * - HTML5 mode: `origin + basePath + appUrl` — + * `'http://host/users/42?q=1'`. In-memory mode the origin is `''` + * (`'/users/42?q=1'`). + */ + absUrl(): string; + + /** + * Get the scheme of the browser URL WITHOUT the trailing `:` (`'http'`), + * parsed once at construction. In-memory / unparseable-href default: + * `'http'`. + */ + protocol(): string; + + /** + * Get the hostname WITHOUT the port (`'example.com'`), parsed once at + * construction. In-memory / unparseable-href default: `''`. + */ + host(): string; + + /** + * Get the port — the explicit `:port` of the browser URL, else the + * protocol default (`http` → 80, `https` → 443, `ftp` → 21; unknown + * scheme → 80), parsed once at construction. In-memory default: `80`. + */ + port(): number; + + /** + * Get the current History-API state value. Returns `undefined` when no + * state has been set (and always in hashbang mode, where the setter is + * unavailable). In HTML5 mode the initial value seeds from the optional + * `historyRef.state` at construction. + */ + state(): unknown; + /** + * Set the History-API state delivered to `history.pushState` / + * `replaceState` on the next browser write — HTML5 MODE ONLY (AngularJS + * parity). Calling the setter with html5Mode off throws + * `Error('History API state support is available only in HTML5 mode and + * only in browsers supporting HTML5 History API')`. + */ + state(value: unknown): LocationService; + + /** + * Mark the NEXT browser write as a history REPLACE instead of a push — + * fluent, chainable (`$location.replace().path('/next')`). The flag + * auto-clears after the next browser write. Hashbang mode uses the + * optional `locationRef.replace(fullUrl)` seam when present (else a plain + * hash write); HTML5 mode uses `historyRef.replaceState`. + */ + replace(): LocationService; + + // ── framework internals (`$$` prefix — the `Scope.$$phase` precedent). ── + // Wired by `$LocationProvider.$get`; app code MUST NOT call these. + + /** + * Switch the setters from the standalone SYNCHRONOUS browser write to + * digest-scheduled sync: after this call setters only mutate internal + * state, and `$LocationProvider.$get`'s `$rootScope.$watch` flushes to + * the browser once per digest. Irreversible. + * + * @internal + */ + $$setDigestWired(): void; + + /** + * The `absUrl()` of the last COMMITTED (browser-synced) state — the + * `oldUrl` argument of the `$locationChange*` events. + * + * @internal + */ + $$committedAbsUrl(): string; + + /** + * The state value of the last COMMITTED snapshot — the `oldState` + * argument of the `$locationChange*` events. + * + * @internal + */ + $$committedState(): unknown; + + /** + * Re-parse the WORKING state from an absolute browser URL (mode- + * appropriate: hashbang fragment vs HTML5 base-relative) — the + * browser → app half. In HTML5 mode the `state` argument replaces the + * working state value. Does NOT commit — the caller decides (event + * ceremony first). + * + * @internal + */ + $$parseBrowserUrl(href: string, state?: unknown): void; + + /** + * Snapshot the working state as the committed (browser-synced) state. + * + * @internal + */ + $$commit(): void; + + /** + * Restore the working state from the committed snapshot — the revert half + * of a `preventDefault()`ed `$locationChangeStart`. + * + * @internal + */ + $$revert(): void; + + /** + * Write the working state to the browser (hash assignment in hashbang + * mode; `pushState` / `replaceState` in HTML5 mode). Honors — and clears — + * the `replace()` flag; `forceReplace` (used for browser-URL reverts) + * forces replace semantics regardless of the flag. No-op in in-memory + * mode. + * + * @internal + */ + $$writeToBrowser(forceReplace?: boolean): void; + + /** + * Install the browser-event callback invoked (with the live `href` and + * the event state) whenever the seam-registered `hashchange` / `popstate` + * listener fires. While NO callback is installed the service adopts + * browser changes directly (re-parse + commit, no events) — the + * standalone fallback. + * + * @internal + */ + $$onUrlChange(listener: (href: string, state: unknown) => void): void; } diff --git a/src/location/location-url.ts b/src/location/location-url.ts index 168023c..d020e92 100644 --- a/src/location/location-url.ts +++ b/src/location/location-url.ts @@ -269,3 +269,101 @@ export function parseHashbangUrl(absUrl: string, hashPrefix: string): ParsedAppU export function composeHashbangHash(parsed: ParsedAppUrl, hashPrefix: string): string { return `#${hashPrefix}${composeAppUrl(parsed)}`; } + +// ──────────────────────────────────────────────────────────────────────────── +// Server portion + HTML5 mode (spec 040 Slice 2) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Default port by protocol when the URL carries no explicit `:port` — + * AngularJS `DEFAULT_PORTS` parity (`urlUtils.js`). Protocols outside this + * map fall back to `80`. + */ +const DEFAULT_PORTS: Record = { http: 80, https: 443, ftp: 21 }; + +/** + * The SERVER portion of an absolute browser URL — the read-only surface + * behind `$location.protocol()` / `host()` / `port()` / `absUrl()`, parsed + * ONCE at service construction. + */ +export interface ParsedServerUrl { + /** The lowercased scheme WITHOUT the trailing `:` (`'http'`); `'http'` when unparseable. */ + protocol: string; + /** The hostname WITHOUT the port (`'example.com'`); `''` when unparseable. */ + host: string; + /** The explicit `:port`, else the protocol default (80 / 443 / 21; unknown scheme → 80). */ + port: number; + /** The origin as written — `protocol://host[:port]` (`''` when unparseable). */ + origin: string; + /** Everything before the first `#` — the hashbang-mode `absUrl()` base. */ + base: string; +} + +/** + * Matches `scheme://host[:port]` at the start of an absolute URL. A tiny + * pure parser is used instead of the `URL` constructor so a malformed fake + * seam `href` in tests degrades to the documented defaults instead of + * throwing. + */ +const SERVER_URL_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#:]*)(:\d+)?/; + +/** + * Parse the server portion of an absolute browser URL. An unparseable / + * relative input yields the documented defaults (`protocol 'http'`, + * `host ''`, `port 80`, `origin ''`) — the same defaults the in-memory + * (null-`locationRef`) service reports. + */ +export function parseServerUrl(absUrl: string): ParsedServerUrl { + const hashIndex = absUrl.indexOf('#'); + const base = hashIndex === -1 ? absUrl : absUrl.slice(0, hashIndex); + const match = SERVER_URL_RE.exec(absUrl); + if (match === null) { + return { protocol: 'http', host: '', port: 80, origin: '', base }; + } + const protocol = (match[1] ?? '').toLowerCase(); + const host = match[2] ?? ''; + const portGroup = match[3]; + const port = portGroup !== undefined ? Number(portGroup.slice(1)) : (DEFAULT_PORTS[protocol] ?? 80); + return { protocol, host, port, origin: match[0], base }; +} + +/** + * Normalize an HTML5-mode base path to its canonical joinable form: a + * leading `/` is ensured and every trailing `/` is stripped, so the result + * concatenates cleanly with a leading-`/` app URL. The root base `'/'` + * normalizes to `''` (`origin + '' + '/users/42'`). + */ +export function normalizeBasePath(basePath: string): string { + let normalized = basePath.startsWith('/') ? basePath : `/${basePath}`; + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +/** + * Parse an ABSOLUTE browser URL in HTML5 mode into the app-URL triple. The + * base prefix is `origin + normalizeBasePath(basePath)`; the remainder of + * the URL (`/path?search#hash`) parses via {@link parseAppUrl}. + * + * A URL that does NOT start with the base prefix (a foreign origin, or a + * path outside the configured base) yields the EMPTY app URL + * (`{ path: '/', search: {}, hash: '' }`) — the documented simple rule, + * mirroring the hashbang parser's foreign-fragment behavior. + */ +export function parseHtml5Url(absUrl: string, origin: string, basePath: string): ParsedAppUrl { + const prefix = origin + normalizeBasePath(basePath); + if (prefix !== '' && !absUrl.startsWith(prefix)) { + return { path: '/', search: {}, hash: '' }; + } + return parseAppUrl(absUrl.slice(prefix.length)); +} + +/** + * Compose the browser-visible HTML5-mode URL — `origin + basePath + appUrl` + * (inverse of {@link parseHtml5Url}). This is the string handed to + * `history.pushState` / `replaceState` on every app-driven URL change. + */ +export function composeHtml5Url(origin: string, basePath: string, parsed: ParsedAppUrl): string { + return origin + normalizeBasePath(basePath) + composeAppUrl(parsed); +} diff --git a/src/location/location.ts b/src/location/location.ts index 8d8cd5a..02e2916 100644 --- a/src/location/location.ts +++ b/src/location/location.ts @@ -1,30 +1,44 @@ /** * `createLocation` — the PURE factory behind the `$location` service - * (spec 040 Slice 1). + * (spec 040 Slices 1–2). * * Follows the `createHttpBackend` seam precedent (`src/http/http-backend.ts`): * every browser touchpoint is an injected, narrowly-typed seam - * ({@link LocationRef} / {@link HistoryRef}) that defaults to the real - * `window.location` / `window.history` via feature detection, so the factory + * ({@link LocationRef} / {@link HistoryRef} / {@link AddEventListenerRef}) + * that defaults to the real `window.*` via feature detection, so the factory * is unit-testable with plain fake objects and never trips jsdom's * "Not implemented: navigation" throw (tech §3 mitigation). * - * Slice 1 ships HASHBANG mode only: the app URL (`/path?search#hash`) lives - * in the browser URL's fragment behind the hash prefix (default `'!'`). - * Internal state is parsed ONCE from `locationRef.href` at construction; - * each fluent setter recomposes the hashbang hash and writes it to - * `locationRef.hash` SYNCHRONOUSLY via the private `$$writeToBrowser` - * internal. Slice 2 moves that write to a digest-scheduled sync, adds - * `replace()` (history replace vs push), HTML5 mode (where `historyRef` - * becomes load-bearing), the read-only browser-URL getters, and the - * `$locationChange*` events — the seams are shaped now so those bolt on - * without changing this signature. + * Two URL styles (FS R4): * - * **Null-`locationRef` behavior (documented):** passing `locationRef: null` - * (or running where `window` is undefined) yields a fully-functional - * IN-MEMORY service — state seeds empty (`path '/'`, `search {}`, - * `hash ''`) and `$$writeToBrowser` is a guarded no-op. Getters/setters - * behave identically; only the browser write is skipped. + * - **Hashbang (default)** — the app URL (`/path?search#hash`) lives in the + * browser URL's fragment behind the hash prefix (default `'!'`); writes go + * to `locationRef.hash` (or `locationRef.replace(fullUrl)` under + * `replace()` when the seam provides it). + * - **HTML5 (`html5Mode: true`)** — the app URL lives in + * `pathname + search + hash` relative to `origin + basePath` (origin + * derived from the initial `href`; `basePath` defaults to `'/'`); writes + * go to `historyRef.pushState` / `replaceState` with the current `state()` + * value. + * + * **Write cadence:** STANDALONE (as constructed here) every setter writes to + * the browser SYNCHRONOUSLY — the Slice 1 contract, kept so the factory + * works without any digest wiring. `$LocationProvider.$get` calls + * `$$setDigestWired()`, after which setters only mutate internal state and + * the provider's `$rootScope.$watch` flushes once per digest with the + * `$locationChangeStart` / `$locationChangeSuccess` ceremony. + * + * **Browser events:** `hashchange` + `popstate` are registered through the + * {@link AddEventListenerRef} seam at construction (skipped when the seam or + * `locationRef` is `null`). With no `$$onUrlChange` callback installed + * (standalone) a browser change is adopted directly — re-parse from the live + * `locationRef.href` + commit, no events; the provider installs the callback + * to add the guarded-`$apply` event ceremony. + * + * **Null-`locationRef` (in-memory) behavior:** state seeds empty (`path '/'`, + * `search {}`, `hash ''`), the server getters report the documented defaults + * (`protocol 'http'`, `host ''`, `port 80`), browser writes are guarded + * no-ops, and no listeners register. Getters/setters behave identically. * * @example * ```ts @@ -34,9 +48,9 @@ * const $location = createLocation({ locationRef: fakeLocation }); * * $location.path(); // '/users/42' - * $location.search(); // { q: '1' } + * $location.absUrl(); // 'http://host/#!/users/42?q=1' * $location.path('/next'); // fluent — returns the service - * fakeLocation.hash; // '#!/next?q=1' (written synchronously) + * fakeLocation.hash; // '#!/next?q=1' (standalone: written synchronously) * ``` */ @@ -44,10 +58,14 @@ import type { LocationService, SearchSetValue } from './location-types'; import { composeAppUrl, composeHashbangHash, + composeHtml5Url, normalizePath, parseAppUrl, parseHashbangUrl, + parseHtml5Url, + parseServerUrl, type ParsedAppUrl, + type ParsedServerUrl, type SearchParams, type SearchValue, } from './location-url'; @@ -55,31 +73,63 @@ import { /** The default hash prefix — hashbang URLs read `#!/path` (AngularJS parity). */ export const DEFAULT_HASH_PREFIX = '!'; +/** The exact message thrown by the `state(value)` setter outside HTML5 mode (AngularJS `$location:nostate` parity). */ +export const STATE_REQUIRES_HTML5_MODE_MESSAGE = + 'History API state support is available only in HTML5 mode and only in browsers supporting HTML5 History API'; + /** * The minimal `window.location` surface the service touches — a narrow - * structural seam (the `JsonpDocument` precedent). Slice 1 READS `href` once - * at construction and WRITES `hash` on each setter; Slice 2's `absUrl()` / - * `protocol()` / `host()` / `port()` getters will read `href` live. Tests - * inject a plain `{ href, hash }` object. + * structural seam (the `JsonpDocument` precedent). `href` is read at + * construction (server portion + initial app URL) and LIVE inside the + * browser-event handler; `hash` is written on each hashbang-mode browser + * write. Tests inject a plain `{ href, hash }` object. */ export interface LocationRef { - /** The full absolute browser URL (read at construction; live in Slice 2). */ + /** The full absolute browser URL — read at construction and on browser events. */ href: string; - /** The fragment portion — written (including the leading `#`) on each setter. */ + /** The fragment portion — written (including the leading `#`) on hashbang writes. */ hash: string; + /** + * Optional `window.location.replace` — when present, a hashbang-mode + * write under `replace()` calls it with the FULL absolute URL + * (`base + '#!' + appUrl`) so no history entry is added; when absent the + * write degrades to a plain `hash` assignment (documented seam extension, + * spec 040 Slice 2). + */ + replace?(url: string): void; } /** - * The minimal `window.history` surface — RESERVED for Slice 2 (HTML5 mode's - * `pushState` / `replaceState` and the `replace()` semantics). Accepted in - * {@link CreateLocationArgs} NOW so the seam shape is stable across slices; - * Slice 1 never invokes it. + * The minimal `window.history` surface — load-bearing in HTML5 mode + * (`pushState` / `replaceState` carry the composed browser URL and the + * `state()` value). The optional readonly `state` slot is read ONCE at + * construction in HTML5 mode to seed `state()` (real `window.history` + * provides it; plain fake seams may omit it). */ export interface HistoryRef { pushState(data: unknown, title: string, url?: string | null): void; replaceState(data: unknown, title: string, url?: string | null): void; + /** Optional current history entry state — construction-time `state()` seed (HTML5 mode). */ + readonly state?: unknown; +} + +/** + * The minimal browser-event shape delivered to the seam listener — + * `popstate` events carry `state`; `hashchange` events don't (the handler + * then preserves the current state value). + */ +export interface BrowserUrlEvent { + readonly state?: unknown; } +/** + * The `window.addEventListener` seam — `createLocation` registers ONE + * listener each for `'hashchange'` and `'popstate'` at construction. Tests + * capture the listeners and invoke them manually (the fake-fetch precedent); + * the default binds the global `window.addEventListener` when available. + */ +export type AddEventListenerRef = (type: 'hashchange' | 'popstate', listener: (event: BrowserUrlEvent) => void) => void; + /** * Arguments accepted by {@link createLocation}. */ @@ -92,19 +142,38 @@ export interface CreateLocationArgs { */ readonly locationRef?: LocationRef | null; /** - * Optional `window.history` seam — reserved for Slice 2 (see - * {@link HistoryRef}). Accepted and ignored in Slice 1. + * Optional `window.history` seam — load-bearing in HTML5 mode (see + * {@link HistoryRef}); ignored in hashbang mode. Same `undefined` / + * `null` semantics as `locationRef`. */ readonly historyRef?: HistoryRef | null; + /** + * Optional `window.addEventListener` seam for the `hashchange` / + * `popstate` registrations. Same `undefined` / `null` semantics as + * `locationRef`; also skipped entirely when `locationRef` is `null` + * (there is no live `href` to re-read). + */ + readonly addEventListenerRef?: AddEventListenerRef | null; /** The hashbang prefix after `#`. Defaults to `'!'` (AngularJS parity). */ readonly hashPrefix?: string; + /** HTML5 (clean-URL) mode — default `false` (hashbang). */ + readonly html5Mode?: boolean; + /** + * HTML5-mode base path under the origin — default `'/'`. The app URL is + * everything after `origin + basePath` (see `parseHtml5Url` / + * `composeHtml5Url` in `location-url.ts`). Ignored in hashbang mode. + */ + readonly basePath?: string; } +/** The documented server defaults for in-memory / unparseable-href mode. */ +const IN_MEMORY_SERVER: ParsedServerUrl = { protocol: 'http', host: '', port: 80, origin: '', base: '' }; + /** * Copy a search map defensively — one level deep plus array cloning, which * covers every {@link SearchValue} shape (strings and booleans are - * immutable). Used by the `search()` getter and both setter forms so callers - * can never alias the service's internal state. + * immutable). Used by the `search()` getter, both setter forms, and the + * committed-snapshot copy so callers can never alias internal state. */ function copySearch(search: SearchParams): SearchParams { const copy: SearchParams = {}; @@ -128,58 +197,162 @@ function normalizeSearchValue(value: string | number | boolean | string[]): Sear } /** - * Create a `$location` service (hashbang mode). + * Create a `$location` service (see the file header for the full contract). * - * Parses `locationRef.href` into the internal app-URL triple at construction - * (empty defaults when the seam is `null`), then serves the four fluent - * getter/setter pairs (`path` / `search` / `hash` / `url`) documented on - * {@link LocationService}. Every setter synchronously recomposes the - * hashbang hash and writes it to `locationRef.hash` (Slice 1 behavior — the - * digest-scheduled sync lands in Slice 2). - * - * @param args - Optional browser seams + hash prefix (see {@link CreateLocationArgs}). - * @returns The `$location` service. + * @param args - Optional browser seams + mode configuration (see {@link CreateLocationArgs}). + * @returns The `$location` service (public surface + `$$` framework internals). */ export function createLocation(args: CreateLocationArgs = {}): LocationService { const hashPrefix = args.hashPrefix ?? DEFAULT_HASH_PREFIX; - // Feature-detect `window.location`. `undefined` (the seam not supplied) + const html5 = args.html5Mode ?? false; + const basePath = args.basePath ?? '/'; + // Feature-detect the browser seams. `undefined` (the seam not supplied) // falls back to the global; an explicit `null` (or a missing global) means // "no browser" → in-memory mode (the `documentRef` precedent in // `createHttpBackend`). const locationRef: LocationRef | null = args.locationRef !== undefined ? args.locationRef : typeof window !== 'undefined' ? window.location : null; + const historyRef: HistoryRef | null = + args.historyRef !== undefined ? args.historyRef : typeof window !== 'undefined' ? window.history : null; + const addEventListenerRef: AddEventListenerRef | null = + args.addEventListenerRef !== undefined + ? args.addEventListenerRef + : typeof window !== 'undefined' + ? (type, listener) => { + // Adapt the native Event to the narrow BrowserUrlEvent shape: + // only popstate carries `state` (an instanceof guard, no cast). + window.addEventListener(type, (event) => { + listener(event instanceof PopStateEvent ? { state: event.state } : {}); + }); + } + : null; - // The internal app-URL triple — the single source of truth for every - // getter. Seeded from the browser URL when a seam is present, else the - // empty defaults (documented null-locationRef behavior). + // The server portion — parsed ONCE at construction (`protocol` / `host` / + // `port` are construction-time facts; the hashbang `absUrl()` base too). + const server: ParsedServerUrl = locationRef !== null ? parseServerUrl(locationRef.href) : IN_MEMORY_SERVER; + + /** Parse an absolute browser URL into the app triple, mode-appropriately. */ + const parseBrowserHref = (href: string): ParsedAppUrl => + html5 ? parseHtml5Url(href, server.origin, basePath) : parseHashbangUrl(href, hashPrefix); + + // The internal WORKING app-URL state — the single source of truth for + // every getter. Seeded from the browser URL when a seam is present, else + // the empty defaults (documented null-locationRef behavior). let currentPath = '/'; let currentSearch: SearchParams = {}; let currentHash = ''; + let currentState: unknown = html5 && historyRef !== null ? historyRef.state : undefined; if (locationRef !== null) { - const parsed = parseHashbangUrl(locationRef.href, hashPrefix); + const parsed = parseBrowserHref(locationRef.href); currentPath = parsed.path; currentSearch = parsed.search; currentHash = parsed.hash; } + // The COMMITTED (browser-synced) snapshot — what the `$locationChange*` + // events report as `oldUrl` / `oldState` and what `$$revert()` restores. + let committed = { path: currentPath, search: copySearch(currentSearch), hash: currentHash, state: currentState }; + + // `replace()` marks the NEXT browser write as a history replace; + // `$$setDigestWired()` flips the setters from sync write to dirty-mark; + // `$$onUrlChange` installs the provider's browser-event callback. + let replacePending = false; + let digestWired = false; + let urlChangeListener: ((href: string, state: unknown) => void) | null = null; + /** The current triple as a {@link ParsedAppUrl} — feed for the composers. */ const currentTriple = (): ParsedAppUrl => ({ path: currentPath, search: currentSearch, hash: currentHash }); + /** Compose the FULL browser URL for a triple (mode-appropriate placement). */ + const absUrlFor = (triple: ParsedAppUrl): string => + html5 ? composeHtml5Url(server.origin, basePath, triple) : server.base + composeHashbangHash(triple, hashPrefix); + + const $$commit = (): void => { + committed = { path: currentPath, search: copySearch(currentSearch), hash: currentHash, state: currentState }; + }; + + const $$revert = (): void => { + currentPath = committed.path; + currentSearch = copySearch(committed.search); + currentHash = committed.hash; + currentState = committed.state; + }; + + const $$committedAbsUrl = (): string => + absUrlFor({ path: committed.path, search: committed.search, hash: committed.hash }); + + const $$parseBrowserUrl = (href: string, state?: unknown): void => { + const parsed = parseBrowserHref(href); + currentPath = parsed.path; + currentSearch = parsed.search; + currentHash = parsed.hash; + if (html5) { + currentState = state; + } + }; + + const $$writeToBrowser = (forceReplace = false): void => { + const useReplace = replacePending || forceReplace; + replacePending = false; + if (html5) { + if (historyRef !== null) { + const browserUrl = absUrlFor(currentTriple()); + if (useReplace) { + historyRef.replaceState(currentState, '', browserUrl); + } else { + historyRef.pushState(currentState, '', browserUrl); + } + } + return; + } + if (locationRef === null) { + return; + } + const composedHash = composeHashbangHash(currentTriple(), hashPrefix); + if (useReplace && typeof locationRef.replace === 'function') { + locationRef.replace(server.base + composedHash); + } else { + locationRef.hash = composedHash; + } + }; + /** - * Private internal (deliberately NOT on {@link LocationService}): compose - * the hashbang hash for the current triple and write it to the browser. - * Slice 1 calls it synchronously from every setter; Slice 2 replaces the - * call sites with a digest-scheduled sync + push/replace dispatch. Guarded - * no-op in in-memory mode. + * Setter tail: standalone mode keeps the Slice-1 SYNCHRONOUS write (+ + * commit, so the snapshot tracks the browser); digest-wired mode leaves + * the working state dirty for the provider's per-digest flush. */ - const $$writeToBrowser = (): void => { + const afterMutation = (): void => { + if (!digestWired) { + $$writeToBrowser(); + $$commit(); + } + }; + + /** + * Browser-event funnel — reads the LIVE `locationRef.href`; `popstate` + * events contribute their `state`, `hashchange` events (no `state` slot) + * preserve the current value. With a provider callback installed the + * ceremony is delegated; standalone adopts the change directly. + */ + const handleBrowserEvent = (event: BrowserUrlEvent): void => { if (locationRef === null) { return; } - locationRef.hash = composeHashbangHash(currentTriple(), hashPrefix); + const eventState = 'state' in event ? event.state : currentState; + if (urlChangeListener !== null) { + urlChangeListener(locationRef.href, eventState); + return; + } + $$parseBrowserUrl(locationRef.href, eventState); + $$commit(); }; + if (addEventListenerRef !== null && locationRef !== null) { + addEventListenerRef('hashchange', handleBrowserEvent); + addEventListenerRef('popstate', handleBrowserEvent); + } + function path(): string; function path(value: string): LocationService; function path(value?: string): string | LocationService { @@ -187,7 +360,7 @@ export function createLocation(args: CreateLocationArgs = {}): LocationService { return currentPath; } currentPath = normalizePath(value); - $$writeToBrowser(); + afterMutation(); return service; } @@ -217,7 +390,7 @@ export function createLocation(args: CreateLocationArgs = {}): LocationService { // mutations of the passed object don't leak into the service. currentSearch = copySearch(paramsOrKey); } - $$writeToBrowser(); + afterMutation(); return service; } @@ -228,7 +401,7 @@ export function createLocation(args: CreateLocationArgs = {}): LocationService { return currentHash; } currentHash = value; - $$writeToBrowser(); + afterMutation(); return service; } @@ -242,10 +415,50 @@ export function createLocation(args: CreateLocationArgs = {}): LocationService { currentPath = parsed.path; currentSearch = parsed.search; currentHash = parsed.hash; - $$writeToBrowser(); + afterMutation(); return service; } - const service: LocationService = { path, search, hash, url }; + function state(): unknown; + function state(value: unknown): LocationService; + function state(...valueArgs: [] | [unknown]): unknown { + if (valueArgs.length === 0) { + return currentState; + } + if (!html5) { + throw new Error(STATE_REQUIRES_HTML5_MODE_MESSAGE); + } + currentState = valueArgs[0]; + afterMutation(); + return service; + } + + const service: LocationService = { + path, + search, + hash, + url, + state, + absUrl: () => absUrlFor(currentTriple()), + protocol: () => server.protocol, + host: () => server.host, + port: () => server.port, + replace: () => { + replacePending = true; + return service; + }, + $$setDigestWired: () => { + digestWired = true; + }, + $$committedAbsUrl, + $$committedState: () => committed.state, + $$parseBrowserUrl, + $$commit, + $$revert, + $$writeToBrowser, + $$onUrlChange: (listener) => { + urlChangeListener = listener; + }, + }; return service; } From ae19632d01beb3b9f42ecc4190ceea6b4a1bed61 Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Mon, 6 Jul 2026 20:56:24 -0400 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20ngRoute=20core=20=E2=80=94=20rout?= =?UTF-8?q?e=20matching,=20$routeProvider,=20$routeParams,=20change=20even?= =?UTF-8?q?ts=20(spec=20040=20slice=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- context/spec/040-routing/tasks.md | 10 +- package.json | 5 + rollup.config.mjs | 2 + src/index.ts | 12 + src/route/__tests__/ng-route-module.test.ts | 133 ++++++ src/route/__tests__/route-path.test.ts | 235 +++++++++ src/route/__tests__/route.test.ts | 498 ++++++++++++++++++++ src/route/index.ts | 29 ++ src/route/ng-route-module.ts | 59 +++ src/route/route-path.ts | 102 ++++ src/route/route-provider.ts | 106 +++++ src/route/route-types.ts | 145 ++++++ src/route/route.ts | 150 ++++++ tsconfig.json | 3 +- vitest.config.ts | 1 + 15 files changed, 1484 insertions(+), 6 deletions(-) create mode 100644 src/route/__tests__/ng-route-module.test.ts create mode 100644 src/route/__tests__/route-path.test.ts create mode 100644 src/route/__tests__/route.test.ts create mode 100644 src/route/index.ts create mode 100644 src/route/ng-route-module.ts create mode 100644 src/route/route-path.ts create mode 100644 src/route/route-provider.ts create mode 100644 src/route/route-types.ts create mode 100644 src/route/route.ts diff --git a/context/spec/040-routing/tasks.md b/context/spec/040-routing/tasks.md index fe4a5d9..60943ef 100644 --- a/context/spec/040-routing/tasks.md +++ b/context/spec/040-routing/tasks.md @@ -24,11 +24,11 @@ Each slice keeps the library in a runnable, green state (`pnpm typecheck && pnpm ## Slice 3: `ngRoute` skeleton + route matching + `$routeParams` -- [ ] Scaffold `src/route/` + packaging (`@route/*` alias, `./route` subpath, `ModuleRegistry` merge). **[Agent: rollup-build]** -- [ ] `src/route/route-path.ts` — `pathRegExp` (`:param` / `:param?` / `*wildcard`, `caseInsensitiveMatch`, trailing-slash, query capture). **[Agent: typescript-framework]** -- [ ] `$RouteProvider.when(path, def)` (chainable) + `otherwise(def)`; `src/route/route.ts` `createRoute` core matching on `$locationChangeSuccess` (inline template only — no resolve/redirect yet); `$route.current`/`.routes`. **[Agent: typescript-framework]** -- [ ] `$routeParams` in-place-repopulated factory; `$routeChangeStart`/`$routeChangeSuccess` broadcast; `createModule('ngRoute', [])` wiring. **[Agent: typescript-framework]** -- [ ] Verify: unit — configure routes, drive fake `$location`, assert `$route.current` + `$routeParams` + events + `otherwise` fallback. **[Agent: vitest-testing]** +- [x] Scaffold `src/route/` + packaging (`@route/*` alias, `./route` subpath, `ModuleRegistry` merge). **[Agent: rollup-build]** +- [x] `src/route/route-path.ts` — `pathRegExp` (`:param` / `:param?` / `*wildcard`, `caseInsensitiveMatch`, trailing-slash, query capture). **[Agent: typescript-framework]** +- [x] `$RouteProvider.when(path, def)` (chainable) + `otherwise(def)`; `src/route/route.ts` `createRoute` core matching on `$locationChangeSuccess` (inline template only — no resolve/redirect yet); `$route.current`/`.routes`. **[Agent: typescript-framework]** +- [x] `$routeParams` in-place-repopulated factory; `$routeChangeStart`/`$routeChangeSuccess` broadcast; `createModule('ngRoute', [])` wiring. **[Agent: typescript-framework]** +- [x] Verify: unit — configure routes, drive fake `$location`, assert `$route.current` + `$routeParams` + events + `otherwise` fallback. **[Agent: vitest-testing]** ## Slice 4: `ngView` renders inline-template routes diff --git a/package.json b/package.json index 4127822..14cb32c 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,11 @@ "import": "./dist/esm/location/index.mjs", "require": "./dist/cjs/location/index.cjs", "types": "./dist/types/location/index.d.ts" + }, + "./route": { + "import": "./dist/esm/route/index.mjs", + "require": "./dist/cjs/route/index.cjs", + "types": "./dist/types/route/index.d.ts" } }, "repository": "https://github.com/Mgrdich/my_own_angularjs.git", diff --git a/rollup.config.mjs b/rollup.config.mjs index 7594b9f..770d794 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -42,6 +42,7 @@ const entries = [ { name: 'http/index', input: 'src/http/index.ts' }, { name: 'forms/index', input: 'src/forms/index.ts' }, { name: 'location/index', input: 'src/location/index.ts' }, + { name: 'route/index', input: 'src/route/index.ts' }, ]; // Path aliases declared in `tsconfig.json` are used across the codebase @@ -68,6 +69,7 @@ const tsPathAliases = { '@http/*': ['src/http/*'], '@forms/*': ['src/forms/*'], '@location/*': ['src/location/*'], + '@route/*': ['src/route/*'], }; const bundleConfigs = entries.map((entry) => ({ diff --git a/src/index.ts b/src/index.ts index 6d7be95..b0e2684 100644 --- a/src/index.ts +++ b/src/index.ts @@ -264,6 +264,18 @@ export type { SearchValue, } from './location/index'; +export { createRoute, ngRoute, OTHERWISE_ROUTE_KEY } from './route/index'; +export type { + CompiledRouteEntry, + CreateRouteArgs, + Route, + RouteDefinition, + RouteParams, + RouteParamValue, + RoutePathKey, + RouteService, +} from './route/index'; + export { createModelOptions, defaultModelOptions, diff --git a/src/route/__tests__/ng-route-module.test.ts b/src/route/__tests__/ng-route-module.test.ts new file mode 100644 index 0000000..9155cef --- /dev/null +++ b/src/route/__tests__/ng-route-module.test.ts @@ -0,0 +1,133 @@ +/** + * `ngRoute` module wiring tests (spec 040 Slice 3; FS R1). + * + * `ngRoute` is OPT-IN (the `ngSanitize` precedent): apps compose it via + * `createInjector([ngModule, ngRoute, …])`; a bare `ng` injector carries no + * routing services at all. Registered names: + * + * - `$route` (run-phase, lazy) — produced by `$RouteProvider.$get`. + * - `$routeProvider` (config-phase) — reachable inside `config()` blocks. + * - `$routeParams` (run-phase) — a plain per-injector object that `$route` + * repopulates IN PLACE; the injector hands out the SAME reference `$route` + * mutates. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { ngModule } from '@core/ng-module'; +import { createInjector } from '@di/injector'; +import { createModule, resetRegistry } from '@di/module'; +import { $RouteProvider, ngRoute } from '@route/index'; + +afterEach(() => { + resetRegistry(); + window.location.hash = ''; +}); + +// ──────────────────────────────────────────────────────────────────────────── +// resolution +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngRoute — service resolution', () => { + it('createInjector([ngModule, ngRoute]) resolves $route with the Slice-3 surface', () => { + const injector = createInjector([ngModule, ngRoute]); + const $route = injector.get('$route'); + expect($route.routes).toBeTypeOf('object'); + expect($route.current).toBeUndefined(); + expect(typeof $route.reload).toBe('function'); + }); + + it('$route is a singleton — repeated gets return the SAME reference', () => { + const injector = createInjector([ngModule, ngRoute]); + expect(injector.get('$route')).toBe(injector.get('$route')); + }); + + it('resolves $routeParams as a plain (initially empty) object, singleton across gets', () => { + const injector = createInjector([ngModule, ngRoute]); + const $routeParams = injector.get('$routeParams'); + expect($routeParams).toEqual({}); + expect(injector.get('$routeParams')).toBe($routeParams); + }); + + it('injector.has reports both names on the ngRoute chain', () => { + const injector = createInjector([ngModule, ngRoute]); + expect(injector.has('$route')).toBe(true); + expect(injector.has('$routeParams')).toBe(true); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// $routeParams identity — the injected object IS the one $route mutates +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngRoute — $routeParams identity', () => { + it('the injector-provided $routeParams is the exact object $route repopulates', () => { + const app = createModule('app', []).config([ + '$routeProvider', + (p: $RouteProvider) => { + p.when('/users/:id', {}); + }, + ]); + const injector = createInjector([ngModule, ngRoute, app]); + window.location.hash = '#!/users/42?tab=info'; + + // Inject $routeParams FIRST — the reference must be live before $route + // ever runs a navigation. + const $routeParams = injector.get('$routeParams'); + const $route = injector.get('$route'); // lazy — installs the location listener + injector.get('$rootScope').$digest(); // initial route resolution + + expect($routeParams).toEqual({ id: '42', tab: 'info' }); + expect($route.current?.params).toEqual($routeParams); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// config phase +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngRoute — config-phase $routeProvider', () => { + it('a config block receives the $RouteProvider instance', () => { + const holder: { provider: $RouteProvider | null } = { provider: null }; + const app = createModule('app', []).config([ + '$routeProvider', + (p: $RouteProvider) => { + holder.provider = p; + }, + ]); + createInjector([ngModule, ngRoute, app]); + expect(holder.provider).toBeInstanceOf($RouteProvider); + }); + + it('routes registered in config appear on the run-phase $route.routes table', () => { + const app = createModule('app', []).config([ + '$routeProvider', + (p: $RouteProvider) => { + p.when('/a', { title: 'A' }).otherwise({ title: 'fallback' }); + }, + ]); + const injector = createInjector([ngModule, ngRoute, app]); + const $route = injector.get('$route'); + expect(Object.keys($route.routes)).toEqual(['/a', 'null']); + expect($route.routes['/a']?.title).toBe('A'); + expect($route.routes['null']?.title).toBe('fallback'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// opt-in boundary (FS R1) +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngRoute — opt-in boundary', () => { + it('a bare [ngModule] injector has NO routing services', () => { + const injector = createInjector([ngModule]); + expect(injector.has('$route')).toBe(false); + expect(injector.has('$routeParams')).toBe(false); + }); + + it('the ng chain itself still resolves untouched alongside ngRoute', () => { + const injector = createInjector([ngModule, ngRoute]); + expect(injector.has('$location')).toBe(true); + expect(injector.has('$rootScope')).toBe(true); + }); +}); diff --git a/src/route/__tests__/route-path.test.ts b/src/route/__tests__/route-path.test.ts new file mode 100644 index 0000000..94581e4 --- /dev/null +++ b/src/route/__tests__/route-path.test.ts @@ -0,0 +1,235 @@ +/** + * Unit tests for the INTERNAL route-pattern compiler + matcher + * (spec 040 Slice 3; FS R3). + * + * `route-path.ts` is deliberately NOT exported from the `@route` barrel, so + * this suite imports it directly — the `sanitize-tokenizer` precedent for + * testing internal modules. + * + * Pinned behaviors: + * - `:name` — one segment (`[^/]+`), decoded via `decodeURIComponent`. + * - `:name?` — optional segment; the PRECEDING slash folds into the optional + * group so `/users/:id?` matches BOTH `/users` and `/users/42`; a missing + * optional key is OMITTED from the param map (not set to `undefined`). + * - `:name*` — greedy across slashes; `:name*?` combines both flags. + * - Literal regex chars (`(`, `)`, `.`, `*`, `$`) are escaped. + * - `^…/?$` anchoring — a URL with an extra trailing slash still matches a + * slash-less pattern (FS R3 trailing-slash tolerance). + * - `caseInsensitiveMatch` → the `i` flag. + * - No match → `matchRoute` returns `null`. + */ + +import { describe, expect, it } from 'vitest'; + +import { matchRoute, pathRegExp } from '@route/route-path'; + +/** Compile + match in one step (the common assertion shape below). */ +function match(pattern: string, path: string, caseInsensitiveMatch = false): Record | null { + return matchRoute(pathRegExp(pattern, { caseInsensitiveMatch }), path); +} + +describe('pathRegExp — static paths', () => { + it('matches an identical static path with an empty param map', () => { + expect(match('/about', '/about')).toEqual({}); + }); + + it('does not match a different path', () => { + expect(match('/about', '/contact')).toBeNull(); + }); + + it('does not match a path that merely starts with the pattern (anchored)', () => { + expect(match('/about', '/about/team')).toBeNull(); + }); + + it('does not match a path that merely ends with the pattern (anchored)', () => { + expect(match('/about', '/x/about')).toBeNull(); + }); + + it('matches the root pattern "/"', () => { + expect(match('/', '/')).toEqual({}); + }); +}); + +describe('pathRegExp — single :param', () => { + it('captures one segment', () => { + expect(match('/users/:id', '/users/42')).toEqual({ id: '42' }); + }); + + it('records the key name with optional=false', () => { + expect(pathRegExp('/users/:id', {}).keys).toEqual([{ name: 'id', optional: false }]); + }); + + it('does not match when the segment is absent (non-optional)', () => { + expect(match('/users/:id', '/users')).toBeNull(); + }); + + it('does not match extra trailing segments', () => { + expect(match('/users/:id', '/users/42/extra')).toBeNull(); + }); + + it('a plain :param does not span slashes', () => { + expect(match('/users/:id', '/users/a/b')).toBeNull(); + }); +}); + +describe('pathRegExp — multiple :params', () => { + it('captures each segment under its own name', () => { + expect(match('/a/:x/b/:y', '/a/1/b/2')).toEqual({ x: '1', y: '2' }); + }); + + it('lists the keys in pattern order', () => { + expect(pathRegExp('/a/:x/b/:y', {}).keys).toEqual([ + { name: 'x', optional: false }, + { name: 'y', optional: false }, + ]); + }); + + it('captures adjacent placeholder segments', () => { + expect(match('/pair/:first/:second', '/pair/a/b')).toEqual({ first: 'a', second: 'b' }); + }); +}); + +describe('pathRegExp — optional :param?', () => { + it('/users/:id? matches /users with the key OMITTED', () => { + const params = match('/users/:id?', '/users'); + expect(params).toEqual({}); + expect(params).not.toBeNull(); + expect(Object.keys(params ?? {})).toEqual([]); + }); + + it('/users/:id? matches /users/42 with the key captured', () => { + expect(match('/users/:id?', '/users/42')).toEqual({ id: '42' }); + }); + + it('/users/:id? matches /users/ (trailing slash, segment absent)', () => { + expect(match('/users/:id?', '/users/')).toEqual({}); + }); + + it('records the key with optional=true', () => { + expect(pathRegExp('/users/:id?', {}).keys).toEqual([{ name: 'id', optional: true }]); + }); + + it('an optional param in the middle still matches when absent', () => { + expect(match('/a/:opt?/b', '/a/b')).toEqual({}); + expect(match('/a/:opt?/b', '/a/x/b')).toEqual({ opt: 'x' }); + }); +}); + +describe('pathRegExp — greedy :rest*', () => { + it('spans multiple slashes', () => { + expect(match('/docs/:rest*', '/docs/a/b/c')).toEqual({ rest: 'a/b/c' }); + }); + + it('records the key with optional=false (bare star)', () => { + expect(pathRegExp('/docs/:rest*', {}).keys).toEqual([{ name: 'rest', optional: false }]); + }); + + it('a bare star group is NOT optional — /docs alone does not match', () => { + expect(match('/docs/:rest*', '/docs')).toBeNull(); + }); + + it(':rest*? combines greedy + optional — matches with the segment absent', () => { + expect(match('/docs/:rest*?', '/docs')).toEqual({}); + expect(match('/docs/:rest*?', '/docs/a/b')).toEqual({ rest: 'a/b' }); + expect(pathRegExp('/docs/:rest*?', {}).keys).toEqual([{ name: 'rest', optional: true }]); + }); +}); + +describe('pathRegExp — mixed patterns', () => { + it('combines a segment param and a greedy tail', () => { + expect(match('/u/:id/files/:path*', '/u/7/files/x/y/z')).toEqual({ id: '7', path: 'x/y/z' }); + }); + + it('combines required, optional, and static segments', () => { + expect(match('/shop/:category/:item?', '/shop/toys')).toEqual({ category: 'toys' }); + expect(match('/shop/:category/:item?', '/shop/toys/ball')).toEqual({ category: 'toys', item: 'ball' }); + }); +}); + +describe('pathRegExp — literal regex-char escaping', () => { + it('parentheses are literal', () => { + expect(match('/file(1)', '/file(1)')).toEqual({}); + expect(match('/file(1)', '/file1')).toBeNull(); + }); + + it('a dot is literal — it does not act as a wildcard', () => { + expect(match('/a.b', '/a.b')).toEqual({}); + expect(match('/a.b', '/aXb')).toBeNull(); + }); + + it('a star not attached to a placeholder is literal', () => { + expect(match('/a*b', '/a*b')).toEqual({}); + expect(match('/a*b', '/axb')).toBeNull(); + expect(match('/a*b', '/ab')).toBeNull(); + }); + + it('a dollar sign is literal', () => { + expect(match('/price$', '/price$')).toEqual({}); + expect(match('/price$', '/price')).toBeNull(); + }); + + it('escaped literals compose with placeholders', () => { + expect(match('/v1.0/:id', '/v1.0/9')).toEqual({ id: '9' }); + expect(match('/v1.0/:id', '/v1X0/9')).toBeNull(); + }); +}); + +describe('pathRegExp — trailing-slash tolerance (FS R3)', () => { + it('a URL with an extra trailing slash matches a slash-less pattern', () => { + expect(match('/users', '/users/')).toEqual({}); + }); + + it('a URL without the trailing slash matches a slash-less pattern (identity)', () => { + expect(match('/users', '/users')).toEqual({}); + }); + + it('the tolerance applies after a captured segment too', () => { + expect(match('/users/:id', '/users/42/')).toEqual({ id: '42' }); + }); + + it('a pattern WITH a trailing slash matches the slashed URL form', () => { + expect(match('/users/', '/users/')).toEqual({}); + }); + + it('only ONE extra trailing slash is tolerated', () => { + expect(match('/users', '/users//')).toBeNull(); + }); +}); + +describe('pathRegExp — caseInsensitiveMatch', () => { + it('off (default): a case difference prevents the match', () => { + expect(match('/users/:id', '/Users/42')).toBeNull(); + }); + + it('on: /Users/42 matches /users/:id', () => { + expect(match('/users/:id', '/Users/42', true)).toEqual({ id: '42' }); + }); + + it('on: the captured value keeps its original casing', () => { + expect(match('/users/:name', '/USERS/Bob', true)).toEqual({ name: 'Bob' }); + }); +}); + +describe('matchRoute — decodeURIComponent of captures', () => { + it('decodes percent-encoded characters in a segment capture', () => { + expect(match('/users/:id', '/users/john%20doe')).toEqual({ id: 'john doe' }); + }); + + it('decodes an encoded slash inside a single segment', () => { + expect(match('/users/:id', '/users/a%2Fb')).toEqual({ id: 'a/b' }); + }); + + it('decodes captures in a greedy group', () => { + expect(match('/docs/:rest*', '/docs/a%20b/c')).toEqual({ rest: 'a b/c' }); + }); +}); + +describe('matchRoute — no match', () => { + it('returns null for a non-matching path', () => { + expect(match('/users/:id', '/orders/42')).toBeNull(); + }); + + it('returns null for the empty path against a non-root pattern', () => { + expect(match('/users', '')).toBeNull(); + }); +}); diff --git a/src/route/__tests__/route.test.ts b/src/route/__tests__/route.test.ts new file mode 100644 index 0000000..f1e45f4 --- /dev/null +++ b/src/route/__tests__/route.test.ts @@ -0,0 +1,498 @@ +/** + * `$route` service tests via DI (spec 040 Slice 3; FS R2 / R10 / R11 / R12 / + * R13). + * + * Drive pattern (the Slice-3 contract): + * 1. `createInjector([ngModule, ngRoute, app])` — the app module's config + * block receives `$routeProvider`. + * 2. `injector.get('$route')` BEFORE any digest — `$route` is lazy; its + * `$locationChangeSuccess` listener exists only after first injection. + * 3. `$rootScope.$digest()` — the initial `$location` watch fire + * (`newUrl === oldUrl`) resolves the initial route. + * 4. `$location.path('/x'); $rootScope.$digest()` — the pipeline runs + * synchronously (no await-flushing this slice). + * + * Pinned event shapes: + * - `$broadcast('$routeChangeStart', next, previous)` — CANCELABLE + * (`preventDefault()` aborts; `$route.current` / `$routeParams` untouched). + * - `$broadcast('$routeChangeSuccess', next, previous)`. + * - No match + no previous → NO events (silent bail); no match + previous → + * `Start(undefined, prev)` → `current = undefined` + `$routeParams` + * emptied → `Success(undefined, prev)`. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { ngModule } from '@core/ng-module'; +import type { Scope, ScopeEvent } from '@core/index'; +import { createInjector } from '@di/injector'; +import { createModule, resetRegistry } from '@di/module'; +import type { LocationService } from '@location/index'; +import { + $RouteProvider, + ngRoute, + OTHERWISE_ROUTE_KEY, + type CompiledRouteEntry, + type Route, + type RouteParams, + type RouteService, +} from '@route/index'; + +interface Harness { + $route: RouteService; + $rootScope: Scope; + $location: LocationService; + $routeParams: RouteParams; +} + +/** + * Register an `'app'` module whose config block receives `$routeProvider`, + * build the injector, seed the jsdom hash, and inject `$route` BEFORE any + * digest (the lazy-listener contract). Deps stay `[]` — the module OBJECTS + * are passed directly so a neighbouring `resetRegistry()` cannot evict them + * (the `location-di.test.ts` precedent). + */ +function boot(configure: (routeProvider: $RouteProvider) => void, initialHash: string): Harness { + const app = createModule('app', []).config(['$routeProvider', configure]); + const injector = createInjector([ngModule, ngRoute, app]); + window.location.hash = initialHash; + const $route = injector.get('$route'); + return { + $route, + $rootScope: injector.get('$rootScope'), + $location: injector.get('$location'), + $routeParams: injector.get('$routeParams'), + }; +} + +interface RecordedRouteEvent { + name: string; + next: Route | undefined; + previous: Route | undefined; +} + +/** Record every `$routeChangeStart` / `$routeChangeSuccess` broadcast in fire order. */ +function recordRouteEvents($rootScope: Scope): RecordedRouteEvent[] { + const events: RecordedRouteEvent[] = []; + const listener = (event: ScopeEvent, ...args: unknown[]): void => { + events.push({ + name: event.name, + next: args[0] as Route | undefined, + previous: args[1] as Route | undefined, + }); + }; + $rootScope.$on('$routeChangeStart', listener); + $rootScope.$on('$routeChangeSuccess', listener); + return events; +} + +/** Indexed-access helper — `routes[key]` is `T | undefined` under `noUncheckedIndexedAccess`. */ +function getEntry($route: RouteService, key: string): CompiledRouteEntry { + const entry = $route.routes[key]; + if (entry === undefined) { + throw new Error(`route table has no entry for ${key}`); + } + return entry; +} + +afterEach(() => { + resetRegistry(); + // Keep the shared jsdom URL clean for neighbouring tests — the digest + // flushes `$location` mutations into the real `window.location.hash`. + window.location.hash = ''; +}); + +// ──────────────────────────────────────────────────────────────────────────── +// initial route resolution + first-match-wins +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — matching', () => { + it('resolves the initial route on the first digest (no URL change needed)', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/users/:id', { screen: 'user-detail' }); + }, '#!/users/42'); + expect($route.current).toBeUndefined(); + $rootScope.$digest(); + expect($route.current?.screen).toBe('user-detail'); + expect($route.current?.pathParams).toEqual({ id: '42' }); + }); + + it('first registered match wins when two patterns both match (registration order)', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/users/:id', { which: 'first' }).when('/users/:name', { which: 'second' }); + }, '#!/users/42'); + $rootScope.$digest(); + expect($route.current?.which).toBe('first'); + }); + + it('re-registering the SAME pattern overwrites in place (last-wins hash semantics)', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/a', { v: 1 }).when('/a', { v: 2 }); + }, '#!/a'); + $rootScope.$digest(); + expect($route.current?.v).toBe(2); + expect(Object.keys($route.routes)).toEqual(['/a']); + }); + + it('navigating between routes updates $route.current on the next digest', () => { + const { $route, $rootScope, $location } = boot((p) => { + p.when('/a', { which: 'a' }).when('/b', { which: 'b' }); + }, '#!/a'); + $rootScope.$digest(); + expect($route.current?.which).toBe('a'); + $location.path('/b'); + $rootScope.$digest(); + expect($route.current?.which).toBe('b'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// params merge precedence (R3 / R11) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — params merge', () => { + it('PATH params win over a same-named query value; other query values ride along', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/users/:id', {}); + }, '#!/users/42?id=99&tab=info'); + $rootScope.$digest(); + expect($route.current?.params).toEqual({ id: '42', tab: 'info' }); + expect($route.current?.pathParams).toEqual({ id: '42' }); + }); + + it('search-only params are present when the pattern has no placeholders', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/list', {}); + }, '#!/list?page=3&active'); + $rootScope.$digest(); + expect($route.current?.params).toEqual({ page: '3', active: true }); + expect($route.current?.pathParams).toEqual({}); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// current-route information (R12) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — current-route information (R12)', () => { + it('custom definition fields ride through to $route.current', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/dash', { title: 'Dashboard', requiresAuth: true }); + }, '#!/dash'); + $rootScope.$digest(); + expect($route.current?.title).toBe('Dashboard'); + expect($route.current?.requiresAuth).toBe(true); + }); + + it('current carries originalPath and the stable $$route back-reference into the table', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/users/:id', {}); + }, '#!/users/42'); + $rootScope.$digest(); + expect($route.current?.originalPath).toBe('/users/:id'); + expect($route.current?.$$route).toBe(getEntry($route, '/users/:id')); + expect($route.current?.regexp).toBeInstanceOf(RegExp); + expect($route.current?.keys).toEqual([{ name: 'id', optional: false }]); + }); + + it('when() shallow-copies the definition — later caller mutations do not leak into the table', () => { + const definition = { title: 'before' }; + const { $route, $rootScope } = boot((p) => { + p.when('/a', definition); + definition.title = 'after'; + }, '#!/a'); + $rootScope.$digest(); + expect(getEntry($route, '/a').title).toBe('before'); + expect($route.current?.title).toBe('before'); + }); + + it('the stored table entries pin the reloadOnSearch / reloadOnUrl defaults to true', () => { + const { $route } = boot((p) => { + p.when('/a', {}).when('/b', { reloadOnSearch: false }); + }, '#!/a'); + expect(getEntry($route, '/a').reloadOnSearch).toBe(true); + expect(getEntry($route, '/a').reloadOnUrl).toBe(true); + // An explicit false rides through untouched (normalization only fills gaps). + expect(getEntry($route, '/b').reloadOnSearch).toBe(false); + expect(getEntry($route, '/b').reloadOnUrl).toBe(true); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// $routeParams — same reference forever, repopulated in place (R11) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$routeParams — in-place repopulation (R11)', () => { + it('keeps the SAME object reference across navigations while its contents update', () => { + const { $rootScope, $location, $routeParams } = boot((p) => { + p.when('/users/:id', {}).when('/list', {}); + }, '#!/users/42'); + const reference = $routeParams; + $rootScope.$digest(); + expect($routeParams).toBe(reference); + expect($routeParams).toEqual({ id: '42' }); + + $location.path('/list').search({ page: '2' }); + $rootScope.$digest(); + expect($routeParams).toBe(reference); + expect($routeParams).toEqual({ page: '2' }); + }); + + it('same route, different params (/users/42 → /users/43) updates the values in place', () => { + const { $rootScope, $location, $routeParams } = boot((p) => { + p.when('/users/:id', {}); + }, '#!/users/42'); + $rootScope.$digest(); + expect($routeParams.id).toBe('42'); + $location.path('/users/43'); + $rootScope.$digest(); + expect($routeParams.id).toBe('43'); + }); + + it('is EMPTIED (same reference) when navigation lands on no match with no fallback', () => { + const { $rootScope, $location, $routeParams } = boot((p) => { + p.when('/start/:id', {}); + }, '#!/start/1'); + const reference = $routeParams; + $rootScope.$digest(); + expect($routeParams).toEqual({ id: '1' }); + $location.path('/nowhere'); + $rootScope.$digest(); + expect($routeParams).toBe(reference); + expect($routeParams).toEqual({}); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// otherwise fallback (R2) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — otherwise fallback (R2)', () => { + it('an unmatched URL falls back to the otherwise route with empty params/pathParams', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/a', {}).otherwise({ template: '404', title: 'not found' }); + }, '#!/missing?q=1'); + $rootScope.$digest(); + expect($route.current?.template).toBe('404'); + expect($route.current?.title).toBe('not found'); + expect($route.current?.params).toEqual({}); + expect($route.current?.pathParams).toEqual({}); + expect($route.current?.originalPath).toBeUndefined(); + expect($route.current?.$$route).toBe(getEntry($route, OTHERWISE_ROUTE_KEY)); + }); + + it("stores the otherwise entry under the literal 'null' key", () => { + expect(OTHERWISE_ROUTE_KEY).toBe('null'); + const { $route } = boot((p) => { + p.otherwise({ template: 'x' }); + }, '#!/'); + expect(getEntry($route, 'null').template).toBe('x'); + }); + + it("otherwise('/home') string shorthand stores { redirectTo } — matched as fallback, redirect NOT run this slice", () => { + const { $route, $rootScope, $location } = boot((p) => { + p.when('/a', {}).otherwise('/home'); + }, '#!/missing'); + $rootScope.$digest(); + expect(getEntry($route, OTHERWISE_ROUTE_KEY).redirectTo).toBe('/home'); + // Slice 3: the redirect is stored but does not run — the fallback entry + // itself becomes current and the URL stays put. + expect($route.current?.redirectTo).toBe('/home'); + expect($location.path()).toBe('/missing'); + }); + + it('a registered route still wins over the otherwise fallback', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/a', { which: 'real' }).otherwise({ which: 'fallback' }); + }, '#!/a'); + $rootScope.$digest(); + expect($route.current?.which).toBe('real'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// no-match shapes (pinned event contract) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — no match, no otherwise', () => { + it('initial load with nothing matching and no previous → NO events, current undefined', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/never', {}); + }, '#!/other'); + const events = recordRouteEvents($rootScope); + $rootScope.$digest(); + expect(events).toEqual([]); + expect($route.current).toBeUndefined(); + }); + + it('previous exists → Start(undefined, prev) → current cleared → Success(undefined, prev)', () => { + const { $route, $rootScope, $location } = boot((p) => { + p.when('/start', { which: 'start' }); + }, '#!/start'); + $rootScope.$digest(); + const previous = $route.current; + expect(previous).toBeDefined(); + + const events = recordRouteEvents($rootScope); + $location.path('/nowhere'); + $rootScope.$digest(); + + expect(events).toEqual([ + { name: '$routeChangeStart', next: undefined, previous }, + { name: '$routeChangeSuccess', next: undefined, previous }, + ]); + expect($route.current).toBeUndefined(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// lifecycle events (R10) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — lifecycle events (R10)', () => { + it('initial resolution broadcasts Start(next, undefined) then Success(next, undefined)', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/a', {}); + }, '#!/a'); + const events = recordRouteEvents($rootScope); + $rootScope.$digest(); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(events[0]?.next).toBe($route.current); + expect(events[0]?.previous).toBeUndefined(); + expect(events[1]?.next).toBe($route.current); + expect(events[1]?.previous).toBeUndefined(); + }); + + it('navigation broadcasts Start then Success carrying (next, previous) in that argument order', () => { + const { $route, $rootScope, $location } = boot((p) => { + p.when('/a', { which: 'a' }).when('/b', { which: 'b' }); + }, '#!/a'); + $rootScope.$digest(); + const previous = $route.current; + + const events = recordRouteEvents($rootScope); + $location.path('/b'); + $rootScope.$digest(); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + // The Start payload's `next` IS the object that becomes $route.current. + expect(events[0]?.next).toBe($route.current); + expect(events[0]?.previous).toBe(previous); + expect(events[1]?.next).toBe($route.current); + expect(events[1]?.previous).toBe(previous); + expect($route.current?.which).toBe('b'); + }); + + it('preventDefault() on $routeChangeStart aborts — current and $routeParams untouched, no Success', () => { + const { $route, $rootScope, $location, $routeParams } = boot((p) => { + p.when('/a', { which: 'a' }).when('/b/:id', { which: 'b' }); + }, '#!/a'); + $rootScope.$digest(); + const before = $route.current; + const paramsBefore = { ...$routeParams }; + + let successFired = 0; + $rootScope.$on('$routeChangeStart', (event) => { + event.preventDefault(); + }); + $rootScope.$on('$routeChangeSuccess', () => { + successFired++; + }); + + $location.path('/b/7'); + $rootScope.$digest(); + + expect($route.current).toBe(before); + expect($routeParams).toEqual(paramsBefore); + expect(successFired).toBe(0); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// same route, different params (R11) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — same route, different params', () => { + it('/users/42 → /users/43 rebuilds current (fresh object, same $$route, new params)', () => { + const { $route, $rootScope, $location } = boot((p) => { + p.when('/users/:id', {}); + }, '#!/users/42'); + $rootScope.$digest(); + const first = $route.current; + expect(first?.params).toEqual({ id: '42' }); + + $location.path('/users/43'); + $rootScope.$digest(); + const second = $route.current; + + expect(second).not.toBe(first); + expect(second?.params).toEqual({ id: '43' }); + expect(second?.pathParams).toEqual({ id: '43' }); + expect(second?.$$route).toBe(first?.$$route); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// reload() (R13) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — reload() (R13)', () => { + it('re-runs the pipeline on the NEXT digest: fresh current object, events re-fired', () => { + const { $route, $rootScope } = boot((p) => { + p.when('/a', { title: 'A' }); + }, '#!/a'); + $rootScope.$digest(); + const before = $route.current; + expect(before).toBeDefined(); + + const events = recordRouteEvents($rootScope); + $route.reload(); + // Scheduled via $evalAsync — nothing happens until a digest drains it. + expect(events).toEqual([]); + expect($route.current).toBe(before); + + $rootScope.$digest(); + const after = $route.current; + expect(after).not.toBe(before); + expect(after?.title).toBe('A'); + expect(after?.$$route).toBe(before?.$$route); + expect(events).toEqual([ + { name: '$routeChangeStart', next: after, previous: before }, + { name: '$routeChangeSuccess', next: after, previous: before }, + ]); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// $RouteProvider — registration surface +// ──────────────────────────────────────────────────────────────────────────── + +describe('$RouteProvider — registration surface', () => { + it('when() is chainable (returns the provider)', () => { + const provider = new $RouteProvider(); + expect(provider.when('/a', {})).toBe(provider); + }); + + it('otherwise() is chainable (returns the provider)', () => { + const provider = new $RouteProvider(); + expect(provider.otherwise({ template: 'x' })).toBe(provider); + expect(provider.otherwise('/home')).toBe(provider); + }); + + it('when() throws TypeError SYNCHRONOUSLY on a non-string path', () => { + const provider = new $RouteProvider(); + expect(() => provider.when(42 as unknown as string, {})).toThrow( + new TypeError('$routeProvider.when() expects a string path pattern'), + ); + }); + + it('the TypeError surfaces synchronously out of a config block too', () => { + const app = createModule('app', []).config([ + '$routeProvider', + (p: $RouteProvider) => { + p.when(null as unknown as string, {}); + }, + ]); + expect(() => createInjector([ngModule, ngRoute, app])).toThrow(TypeError); + }); +}); diff --git a/src/route/index.ts b/src/route/index.ts new file mode 100644 index 0000000..806fb5e --- /dev/null +++ b/src/route/index.ts @@ -0,0 +1,29 @@ +/** + * Public barrel for the `@route` module — the opt-in `ngRoute` routing + * companion (spec 040 Slice 3). + * + * Mirrors the `@sanitize` barrel split: the pure factory + * ({@link createRoute}), the config-phase provider ({@link $RouteProvider} + * — reachable via `injector.get('$routeProvider')` during `config()` and + * deliberately NOT re-exported from the root `src/index.ts` barrel, the + * `$SanitizeProvider` precedent), the opt-in {@link ngRoute} module, and + * the public contract types. + * + * The pattern compiler (`route-path.ts` — `pathRegExp` / `matchRoute`) is + * INTERNAL and stays out of this barrel; consumers see only the compiled + * shape through {@link CompiledRouteEntry}. + */ + +export { createRoute, OTHERWISE_ROUTE_KEY } from './route'; +export type { CreateRouteArgs } from './route'; +export { $RouteProvider } from './route-provider'; +export { ngRoute } from './ng-route-module'; +export type { + CompiledRouteEntry, + Route, + RouteDefinition, + RouteParams, + RouteParamValue, + RoutePathKey, + RouteService, +} from './route-types'; diff --git a/src/route/ng-route-module.ts b/src/route/ng-route-module.ts new file mode 100644 index 0000000..c225dc2 --- /dev/null +++ b/src/route/ng-route-module.ts @@ -0,0 +1,59 @@ +/** + * `ngRoute` — opt-in DI module that ships the `$route` / `$routeParams` + * routing services (spec 040 Slice 3; FS R1). + * + * Unlike `ngModule` (the always-on AngularJS-core module), `ngRoute` + * registers independently. Apps that want routing compose it alongside the + * core via `createInjector([ngModule, ngRoute, myApp])` (or a module + * `requires` chain declaring both `'ng'` and `'ngRoute'`); apps that never + * navigate between screens simply omit it and pay neither the code-size nor + * the runtime cost — the `ngSanitize` precedent. + * + * Registered names: + * - `$route` (run-phase service): the {@link RouteService} produced by + * `$RouteProvider.$get` — lazy, so its `$locationChangeSuccess` listener + * only exists once something injects `$route` (upstream's eager + * instantiation run block is a later slice, alongside + * `eagerInstantiationEnabled`). + * - `$routeProvider` (config-phase): the configurator class + * {@link $RouteProvider}, exposing `when` / `otherwise` for `config()` + * blocks. + * - `$routeParams` (run-phase service): a plain per-injector object that + * `$route` repopulates IN PLACE on each navigation (upstream + * `$RouteParamsProvider` pattern) — injectable on its own so controllers + * read live URL values without depending on `$route`. + * + * `$route`'s `$get` injects `$rootScope` / `$location` from the core `ng` + * module — resolved at run time through the injector, so `ngRoute` itself + * declares no static `requires` (matching `ngSanitize`'s empty-deps shape); + * composing it without `ng` fails at first `$route` injection, not at + * module registration. + * + * The registry-augmentation block below adds a top-level `ngRoute` key to + * `ModuleRegistry` (the `ngSanitize` precedent in + * `src/sanitize/ng-sanitize-module.ts`). TypeScript's declaration merging + * composes it with the `ng` / `ngSanitize` entries into the single registry + * the DI tuple typings consult. + */ + +import { createModule } from '@di/module'; +import { $RouteProvider } from '@route/route-provider'; +import type { RouteParams, RouteService } from '@route/route-types'; + +declare module '@di/di-types' { + interface ModuleRegistry { + ngRoute: { + registry: { + $route: RouteService; + $routeParams: RouteParams; + }; + config: { + $routeProvider: $RouteProvider; + }; + }; + } +} + +export const ngRoute = createModule('ngRoute', []) + .provider('$route', $RouteProvider) + .factory('$routeParams', [(): RouteParams => ({})]); diff --git a/src/route/route-path.ts b/src/route/route-path.ts new file mode 100644 index 0000000..f260d78 --- /dev/null +++ b/src/route/route-path.ts @@ -0,0 +1,102 @@ +/** + * Route-pattern compiler + matcher (spec 040 Slice 3) — INTERNAL to + * `@route` (deliberately NOT exported from the barrel; the public surface + * exposes only the compiled shape via `CompiledRouteEntry`). + * + * {@link pathRegExp} is a direct port of AngularJS `$routeProvider`'s + * `pathRegExp` (route.js): + * + * - `:name` — a named group matching one segment (`[^/]+`). + * - `:name?` — an OPTIONAL segment; the PRECEDING slash folds into the + * optional group, so `/users/:id?` matches both `/users` and `/users/42`. + * - `:name*` — a GREEDY "rest of the path" group (`(.+?)` inside the + * anchored pattern, so it spans slashes). + * - `:name*?` — optional greedy group (both flags combine). + * - Every literal char is regex-escaped (`(`, `)`, `.` before the + * placeholder pass; `/`, `$`, `*` after it — the after-pass harmlessly + * escapes the `/` inside the generated `[^/]+` classes too, exactly like + * upstream). + * - The whole pattern is anchored `^...$` with `/?` appended before the + * `$`, so a URL differing from the pattern only by a trailing slash still + * matches (FS R3). Upstream achieves the same tolerance by registering a + * companion redirect route per pattern; we fold it into the regexp + * instead — a documented mechanism divergence with identical observable + * matching, minus the extra table entries and redirect hops. + * - `caseInsensitiveMatch` → the `i` flag (FS R3). + */ + +import type { RoutePathKey } from './route-types'; + +/** The compiled form of a route pattern — the matcher plus its placeholder names in capture-group order. */ +export interface CompiledPath { + /** Anchored (`^...$`), trailing-slash tolerant (`/?$`) matcher. */ + regexp: RegExp; + /** One entry per capture group, in pattern order. */ + keys: RoutePathKey[]; +} + +/** Options accepted by {@link pathRegExp}. */ +export interface PathRegExpOptions { + /** Compile with the `i` flag so `/Users/42` matches `/users/:id`. Default `false`. */ + caseInsensitiveMatch?: boolean; +} + +/** + * Compile an AngularJS route pattern into a {@link CompiledPath}. + * + * @example + * ```ts + * const compiled = pathRegExp('/users/:id?', {}); + * compiled.regexp.test('/users'); // true (optional segment absent) + * compiled.regexp.test('/users/42'); // true + * compiled.keys; // [{ name: 'id', optional: true }] + * ``` + */ +export function pathRegExp(pattern: string, opts: PathRegExpOptions): CompiledPath { + const keys: RoutePathKey[] = []; + + const source = pattern + .replace(/([().])/g, '\\$1') + .replace( + /(\/)?:(\w+)(\*\?|[?*])?/g, + (_match, slash: string | undefined, key: string, option: string | undefined) => { + const optional = option === '?' || option === '*?'; + const star = option === '*' || option === '*?'; + keys.push({ name: key, optional }); + const lead = slash ?? ''; + return (optional ? '(?:' + lead : lead + '(?:') + (star ? '(.+?)' : '([^/]+)') + (optional ? '?)?' : ')'); + }, + ) + .replace(/([/$*])/g, '\\$1'); + + return { + regexp: new RegExp('^' + source + '/?$', opts.caseInsensitiveMatch === true ? 'i' : ''), + keys, + }; +} + +/** + * Match a path against a {@link CompiledPath}. + * + * Returns the decoded path-param map (`decodeURIComponent` per captured + * value; a missing optional placeholder is OMITTED, not set to + * `undefined`), or `null` when the path does not match. + */ +export function matchRoute(compiled: CompiledPath, path: string): Record | null { + const match = compiled.regexp.exec(path); + if (match === null) { + return null; + } + const params: Record = {}; + for (let i = 1; i < match.length; i++) { + const key = compiled.keys[i - 1]; + const value = match[i]; + // Upstream `if (key && val)` — an unparticipating (missing optional) + // group yields `undefined` and is omitted; the segment groups can never + // capture the empty string, so truthiness reduces to a string check. + if (key !== undefined && typeof value === 'string' && value !== '') { + params[key.name] = decodeURIComponent(value); + } + } + return params; +} diff --git a/src/route/route-provider.ts b/src/route/route-provider.ts new file mode 100644 index 0000000..2032571 --- /dev/null +++ b/src/route/route-provider.ts @@ -0,0 +1,106 @@ +/** + * `$RouteProvider` — config-phase route registration (spec 040 Slice 3; + * FS R2 / R3). + * + * - {@link $RouteProvider.when | when(path, route)} — shallow-copies the + * definition (so later caller mutations don't leak into the table), + * normalizes the `reloadOnSearch` / `reloadOnUrl` defaults to `true` + * (upstream parity — 1.7+ added `reloadOnUrl`), compiles the pattern via + * the internal `pathRegExp`, and stores the entry keyed by the pattern + * string. Chainable; re-registering the same pattern overwrites in place + * (last-wins, upstream hash semantics). A non-string path throws + * `TypeError` synchronously (config-phase programmer error — the + * `.directive` name-validation precedent, never `$exceptionHandler`). + * - {@link $RouteProvider.otherwise | otherwise(route)} — the no-match + * fallback, stored under the `'null'` key (upstream `routes[null]`). The + * string shorthand `otherwise('/home')` normalizes to + * `{ redirectTo: '/home' }` (upstream parity). + * - `$get` is `['$rootScope', '$location', '$routeParams', factory]` — the + * exact collaborators the Slice-3 pipeline consumes. `$q` / `$injector` / + * `$templateRequest` are deliberately NOT injected yet; the template + + * `resolve` slice widens the dep list when it adds the async stages. + * + * TRAILING-SLASH MECHANISM (documented divergence): upstream `when()` + * registers a companion `{ redirectTo }` route per pattern (pattern ± + * trailing slash); this implementation folds the tolerance into the + * compiled regexp instead (`/?` before the `$` anchor — see + * `route-path.ts`), so `$route.routes` carries exactly ONE entry per + * `when()` call and no redirect hop occurs. Observable matching is + * identical (FS R3: a trailing-slash-only difference never prevents a + * match). + */ + +import type { Scope } from '@core/index'; +import type { LocationService } from '@location/index'; +import { pathRegExp } from './route-path'; +import { createRoute, OTHERWISE_ROUTE_KEY } from './route'; +import type { CompiledRouteEntry, RouteDefinition, RouteParams, RouteService } from './route-types'; + +/** Shallow-copy a definition and normalize the two reload defaults to `true`. */ +function normalizeDefinition(route: RouteDefinition): CompiledRouteEntry { + const entry: CompiledRouteEntry = { ...route }; + if (entry.reloadOnSearch === undefined) { + entry.reloadOnSearch = true; + } + if (entry.reloadOnUrl === undefined) { + entry.reloadOnUrl = true; + } + return entry; +} + +export class $RouteProvider { + // `$$` prefix mirrors the AngularJS internal-field convention (the + // `$LocationProvider.$$hashPrefix` precedent). Insertion order of the + // plain object IS the registration order the matcher walks. + private readonly $$routes: Record = {}; + + /** + * Register a route: URL pattern → screen definition (FS R2). See the + * file header for copy/default/compile semantics; see `route-path.ts` + * for the pattern grammar (`:name` / `:name?` / `:name*`, + * `caseInsensitiveMatch`, trailing-slash tolerance). + */ + when(path: string, route: RouteDefinition): this { + if (typeof path !== 'string') { + throw new TypeError('$routeProvider.when() expects a string path pattern'); + } + const entry = normalizeDefinition(route); + const compiled = pathRegExp(path, { caseInsensitiveMatch: entry.caseInsensitiveMatch === true }); + entry.originalPath = path; + entry.regexp = compiled.regexp; + entry.keys = compiled.keys; + this.$$routes[path] = entry; + return this; + } + + /** + * Register the no-match fallback (FS R2). A string argument is the + * upstream shorthand for `{ redirectTo: value }`. Stored as the + * pattern-less `'null'` entry — `parseRoute` falls back to it with empty + * `params` / `pathParams`. + */ + otherwise(route: RouteDefinition | string): this { + const definition: RouteDefinition = typeof route === 'string' ? { redirectTo: route } : route; + this.$$routes[OTHERWISE_ROUTE_KEY] = normalizeDefinition(definition); + return this; + } + + /** + * Injector-facing factory — freezes the table as-registered and wires + * {@link createRoute} to the digest collaborators. `$routeParams` is the + * SEPARATE per-injector object registered on `ngRoute`; `$route` mutates + * it in place (upstream `$RouteParamsProvider` pattern). + */ + $get = [ + '$rootScope', + '$location', + '$routeParams', + ($rootScope: Scope, $location: LocationService, $routeParams: RouteParams): RouteService => + createRoute({ + rootScope: $rootScope, + location: $location, + routeParams: $routeParams, + routes: this.$$routes, + }), + ] as const; +} diff --git a/src/route/route-types.ts b/src/route/route-types.ts new file mode 100644 index 0000000..5ef004e --- /dev/null +++ b/src/route/route-types.ts @@ -0,0 +1,145 @@ +/** + * Public contract types for the `ngRoute` module (spec 040 Slice 3). + * + * {@link RouteDefinition} is what `$routeProvider.when()` accepts — it + * already CARRIES the template / controller / redirect / resolve fields so + * later slices (template fetching, `resolve` orchestration, redirects, + * `ngView`) only add behavior, never reshape the contract. The index + * signature admits arbitrary custom fields (upstream parity — FS R12: + * developer metadata like a page title is readable off `$route.current`). + * + * {@link Route} is a `$route.current` value — a shallow COPY of the matched + * (compiled) definition plus the captured `params` / `pathParams`. Later + * slices add `locals` (resolved `resolve` values). + * + * {@link RouteService} is the run-phase `$route` surface. + */ + +import type { Invokable } from '@di/di-types'; + +/** + * A single route-param value. Path placeholders always capture decoded + * `string`s; query (search) values ride through from `$location.search()` + * and may be `boolean` (bare flag keys) or `string[]` (repeated keys) — + * the `SearchValue` shape of `@location`. + */ +export type RouteParamValue = string | boolean | string[]; + +/** + * The combined route-params map — upstream builds it as + * `extend({}, $location.search(), pathParams)`, so PATH params win over + * same-named query values (see {@link Route.params}). + */ +export type RouteParams = Record; + +/** + * A named placeholder captured from a route pattern — `:name` / + * `:name?` / `:name*` (the compiled entry's `keys` list, in + * pattern order). + */ +export interface RoutePathKey { + /** The placeholder name (`'id'` for `:id`). */ + name: string; + /** `true` for `:name?` (and `:name*?`) — the segment may be absent. */ + optional: boolean; +} + +/** + * A route definition — the object `$routeProvider.when(path, definition)` + * accepts (FS R2). All behavior fields are optional; arbitrary EXTRA fields + * are allowed (index signature) and ride through to `$route.current` + * untouched (FS R12 — upstream parity). + * + * Slice 3 acts only on `caseInsensitiveMatch`, `reloadOnSearch` / + * `reloadOnUrl` DEFAULTING (both `true` when omitted), and the pattern + * itself; `template` / `templateUrl` / `controller` / `controllerAs` / + * `redirectTo` / `resolve` are carried verbatim for the later slices that + * consume them (templates, controllers, redirects, resolve). + */ +export interface RouteDefinition { + /** Inline template — a string or a `(routeParams) => string` function (consumed by the ngView slice). */ + template?: string | ((routeParams: RouteParams) => string); + /** Remote template URL — a string or a `(routeParams) => string` function (consumed by the template slice). */ + templateUrl?: string | ((routeParams: RouteParams) => string); + /** Per-route controller — a registered name (`'Ctrl'` / `'Ctrl as vm'`) or an invokable (consumed by the ngView slice). */ + controller?: string | Invokable; + /** `controllerAs` alias for the route controller (consumed by the ngView slice). */ + controllerAs?: string; + /** + * Redirect target — a path string or a + * `(pathParams, path, search) => string` function (consumed by the + * redirect slice). + */ + redirectTo?: string | ((pathParams: Record, path: string, search: RouteParams) => string); + /** Map of dependencies to pre-load before the route activates (consumed by the resolve slice). */ + resolve?: Record; + /** Rebuild the screen on query-only URL changes. Default `true` (normalized by `when()`). */ + reloadOnSearch?: boolean; + /** Rebuild the screen on any URL change to the same route. Default `true` (normalized by `when()`). */ + reloadOnUrl?: boolean; + /** Match the pattern ignoring letter case. Default `false`. */ + caseInsensitiveMatch?: boolean; + /** Arbitrary developer metadata — readable off `$route.current` (upstream parity, FS R12). */ + [key: string]: unknown; +} + +/** + * A COMPILED route-table entry — the {@link RouteDefinition} shallow copy + * `when()` stores, extended with the original pattern and the compiled + * matcher (upstream keeps `regexp` / `keys` directly on the entry). The + * `otherwise` fallback entry has NO pattern, so all three are optional. + */ +export interface CompiledRouteEntry extends RouteDefinition { + /** The pattern string exactly as passed to `when()`. Absent on the `otherwise` entry. */ + originalPath?: string; + /** The compiled matcher — anchored, trailing-slash tolerant. Absent on the `otherwise` entry. */ + regexp?: RegExp; + /** The named placeholders, in pattern order (one per capture group). Absent on the `otherwise` entry. */ + keys?: RoutePathKey[]; +} + +/** + * A `$route.current` value — a shallow copy of the matched compiled entry + * plus the captured URL values (FS R11 / R12). Later slices add `locals`. + */ +export interface Route extends CompiledRouteEntry { + /** + * Combined query + path values — upstream + * `extend({}, $location.search(), pathParams)`: PATH params WIN over a + * same-named query value. The `otherwise` fallback gets `{}`. + */ + params: RouteParams; + /** ONLY the values captured from path placeholders. The `otherwise` fallback gets `{}`. */ + pathParams: Record; + /** + * The route-table entry this value was built from (upstream `$$route`) — + * the STABLE reference later slices compare to detect same-route + * query-only changes (`reloadOnSearch` / `$routeUpdate`). + */ + $$route?: CompiledRouteEntry; +} + +/** + * `$route` — the run-phase routing service (FS R2 / R12 / R13). + * + * Instantiated lazily on first injection; its `$locationChangeSuccess` + * listener resolves the initial route on the FIRST digest after + * instantiation (the `$location` watch broadcasts the initial + * `newUrl === oldUrl` pair). + */ +export interface RouteService { + /** + * The compiled route table, keyed by the pattern passed to `when()`. The + * `otherwise` fallback is stored under the literal key `'null'` (upstream + * stores it as `routes[null]`, which coerces to the same string key). + */ + routes: Record; + /** The active route, or `undefined` when nothing matches and no fallback is registered. */ + current: Route | undefined; + /** + * Re-run the current route on demand (FS R13). Schedules the update via + * `$rootScope.$evalAsync` (upstream mechanism), so it lands on the next + * digest tick. + */ + reload(): void; +} diff --git a/src/route/route.ts b/src/route/route.ts new file mode 100644 index 0000000..01085d5 --- /dev/null +++ b/src/route/route.ts @@ -0,0 +1,150 @@ +/** + * `createRoute` — the pure `$route` factory (spec 040 Slice 3). + * + * Listens on `$rootScope.$on('$locationChangeSuccess')` — including the + * INITIAL first-digest fire (`newUrl === oldUrl`) the `$location` watch + * broadcasts, which is what resolves the initial route without any URL + * change. Each fire runs the (synchronous this slice — designed to grow the + * async template/resolve stages in a later slice) update pipeline: + * + * 1. Match `$location.path()` against the compiled table in REGISTRATION + * ORDER — first match wins (upstream iterates the routes hash in + * insertion order); no match → the `otherwise` fallback (stored under + * the `'null'` key); no fallback → `undefined`. + * 2. Build `next`: a shallow copy of the matched entry + + * `params` (= `{ ...$location.search(), ...pathParams }` — PATH params + * WIN on collision, upstream `extend({}, search, pathParams)`) + + * `pathParams` + the stable `$$route` back-reference. + * 3. Broadcast `$routeChangeStart(next, previous)` — CANCELABLE + * (`preventDefault()` aborts the navigation, upstream parity; the + * current route and `$routeParams` stay untouched). + * 4. Set `$route.current = next`, repopulate the injected `$routeParams` + * IN PLACE (clear own keys, copy `next.params`) so references injected + * elsewhere stay live (upstream `$RouteParamsProvider` pattern). + * 5. Broadcast `$routeChangeSuccess(current, previous)`. + * + * NO-MATCH SHAPE (pinned to upstream `updateRoute`): when neither a `next` + * nor a `previous` route exists (initial load, nothing matches, no + * `otherwise`) the pipeline bails BEFORE any broadcast — no events at all. + * When a `previous` exists and nothing matches anymore, the pair broadcasts + * WITH `next === undefined` (`$routeChangeStart(undefined, previous)` → + * `$routeChangeSuccess(undefined, previous)`) and `$route.current` becomes + * `undefined` / `$routeParams` empties. + * + * `reload()` sets the `forceReload` flag and schedules the update via + * `$rootScope.$evalAsync(update)` (the upstream mechanism) — the flag is + * consumed by the `reloadOnSearch` / `$routeUpdate` short-circuit a later + * slice adds; this slice rebuilds on every pass regardless. + */ + +import type { Scope } from '@core/index'; +import type { LocationService } from '@location/index'; +import { matchRoute } from './route-path'; +import type { CompiledRouteEntry, Route, RouteParams, RouteService } from './route-types'; + +/** + * The route-table key the `otherwise` fallback is stored under. Upstream + * stores it as `routes[null]`, which JavaScript coerces to the same string + * key — kept identical so `$route.routes` has the upstream runtime shape. + */ +export const OTHERWISE_ROUTE_KEY = 'null'; + +/** Collaborators {@link createRoute} needs — wired by `$RouteProvider.$get`. */ +export interface CreateRouteArgs { + /** `$rootScope` — event broadcast + `$evalAsync` scheduling. */ + rootScope: Scope; + /** `$location` — the `path()` / `search()` reads the matcher consumes. */ + location: LocationService; + /** The injected `$routeParams` object — repopulated IN PLACE on each navigation. */ + routeParams: RouteParams; + /** The compiled route table built by `$routeProvider.when()` / `.otherwise()`. */ + routes: Record; +} + +/** Build the `$route` service (see the file header for the pipeline). */ +export function createRoute(args: CreateRouteArgs): RouteService { + const { rootScope, location, routeParams, routes } = args; + + let forceReload = false; + + const $route: RouteService = { + routes, + current: undefined, + reload(): void { + forceReload = true; + rootScope.$evalAsync(update); + }, + }; + + /** + * Match the current `$location.path()` against the table (registration + * order, first match wins), falling back to the `otherwise` entry, else + * `undefined` — upstream `parseRoute`. + */ + function parseRoute(): Route | undefined { + const path = location.path(); + for (const [key, entry] of Object.entries(routes)) { + if (key === OTHERWISE_ROUTE_KEY || entry.regexp === undefined || entry.keys === undefined) { + continue; + } + const pathParams = matchRoute({ regexp: entry.regexp, keys: entry.keys }, path); + if (pathParams !== null) { + return { + ...entry, + // PATH params win over same-named query values — upstream + // `extend({}, $location.search(), pathParams)`. + params: { ...location.search(), ...pathParams }, + pathParams, + $$route: entry, + }; + } + } + const fallback = routes[OTHERWISE_ROUTE_KEY]; + return fallback === undefined ? undefined : { ...fallback, params: {}, pathParams: {}, $$route: fallback }; + } + + /** The navigation pipeline — steps 1–5 of the file header. */ + function update(): void { + const next = parseRoute(); + const previous = $route.current; + + // Upstream `updateRoute`'s `next || last` gate: with NOTHING to leave + // and NOTHING to enter (no match, no fallback, no active route) the + // pass is silent — no events, no state writes. + if (next === undefined && previous === undefined) { + return; + } + + // The force flag is consumed by the `reloadOnSearch` / `$routeUpdate` + // short-circuit a later slice adds (upstream resets it on the navigate + // branch); this slice rebuilds on every pass, so it is read (the + // `void strictDi` bootstrap discard precedent) and cleared. + void forceReload; + forceReload = false; + + const startEvent = rootScope.$broadcast('$routeChangeStart', next, previous); + if (startEvent.defaultPrevented) { + return; // navigation vetoed — current route and $routeParams untouched + } + + $route.current = next; + + // Repopulate $routeParams IN PLACE so injected references stay live + // (upstream $RouteParamsProvider pattern): clear own keys, copy params. + for (const key of Object.keys(routeParams)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- in-place repopulation is the $routeParams contract (injected references must observe the new values); keys are own enumerable route-param names, not arbitrary input + delete routeParams[key]; + } + if (next !== undefined) { + Object.assign(routeParams, next.params); + } + + rootScope.$broadcast('$routeChangeSuccess', next, previous); + } + + rootScope.$on('$locationChangeSuccess', () => { + update(); + }); + + return $route; +} diff --git a/tsconfig.json b/tsconfig.json index d453b9b..81ffacc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,8 @@ "@cache/*": ["./src/cache/*"], "@http/*": ["./src/http/*"], "@forms/*": ["./src/forms/*"], - "@location/*": ["./src/location/*"] + "@location/*": ["./src/location/*"], + "@route/*": ["./src/route/*"] } }, "include": ["src"], diff --git a/vitest.config.ts b/vitest.config.ts index bf8f975..b48e668 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ '@http': path.resolve(__dirname, 'src/http'), '@forms': path.resolve(__dirname, 'src/forms'), '@location': path.resolve(__dirname, 'src/location'), + '@route': path.resolve(__dirname, 'src/route'), }, }, test: { From d7dabd0026f98ce386ed7a3ee4ac1ef238d7ef1e Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Mon, 6 Jul 2026 21:17:53 -0400 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20ngView=20=E2=80=94=20inline-templ?= =?UTF-8?q?ate=20route=20rendering=20with=20per-route=20controller=20(spec?= =?UTF-8?q?=20040=20slice=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
→ 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) --- context/spec/040-routing/tasks.md | 4 +- src/route/__tests__/ng-view.test.ts | 443 ++++++++++++++++++++++++++++ src/route/ng-route-module.ts | 11 +- src/route/ng-view.ts | 353 ++++++++++++++++++++++ 4 files changed, 808 insertions(+), 3 deletions(-) create mode 100644 src/route/__tests__/ng-view.test.ts create mode 100644 src/route/ng-view.ts diff --git a/context/spec/040-routing/tasks.md b/context/spec/040-routing/tasks.md index 60943ef..437f758 100644 --- a/context/spec/040-routing/tasks.md +++ b/context/spec/040-routing/tasks.md @@ -32,8 +32,8 @@ Each slice keeps the library in a runnable, green state (`pnpm typecheck && pnpm ## Slice 4: `ngView` renders inline-template routes -- [ ] `src/route/ng-view.ts` — `ngInclude`-clone directive (`transclude: 'element'`, comment anchor, wrapper-div, stale-token teardown + dual cleanup), child scope, route controller + `controllerAs`, `$viewContentLoaded`; register on `ngRoute`. **[Agent: typescript-framework]** -- [ ] Verify: jsdom integration (`resetRegistry`, `['ng','ngRoute']`) — navigate via fake `$location`, assert screen swap, controller + `controllerAs`, prior-clone teardown. **[Agent: vitest-testing]** +- [x] `src/route/ng-view.ts` — `ngInclude`-clone directive (`transclude: 'element'`, comment anchor, wrapper-div, stale-token teardown + dual cleanup), child scope, route controller + `controllerAs`, `$viewContentLoaded`; register on `ngRoute`. **[Agent: typescript-framework]** +- [x] Verify: jsdom integration (`resetRegistry`, `['ng','ngRoute']`) — navigate via fake `$location`, assert screen swap, controller + `controllerAs`, prior-clone teardown. **[Agent: vitest-testing]** ## Slice 5: Remote templates (`templateUrl`) diff --git a/src/route/__tests__/ng-view.test.ts b/src/route/__tests__/ng-view.test.ts new file mode 100644 index 0000000..5fb7816 --- /dev/null +++ b/src/route/__tests__/ng-view.test.ts @@ -0,0 +1,443 @@ +/** + * `ngView` directive tests via DI (spec 040 Slice 4; FS R5 view placeholder / + * R7 per-route controller — INLINE templates only this slice). + * + * Drive pattern (extends the Slice-3 `route.test.ts` contract with a compiled + * page): + * 1. `createInjector([ngModule, ngRoute, app])` — the app module's config + * block receives `$routeProvider`. + * 2. `root.innerHTML = ''` + + * `injector.get('$compile')(root)($rootScope)` — compiling resolves the + * `ngViewDirective` provider (instantiating `$route`, wiring its + * `$locationChangeSuccess` listener), and linking runs the initial + * `update()` (empty — no route resolved yet). + * 3. `$location.path('/x'); $rootScope.$digest()` — the whole pipeline is + * synchronous this slice: location sync → `$routeChangeSuccess` → render. + * One digest suffices; no await-flushing. + * + * Pinned DOM shape: the `transclude: 'element'` foundation swaps the host for + * a `` Comment placeholder; the rendered template's nodes + * live inside a wrapper `
` inserted as the placeholder's NEXT SIBLING + * (the `ngInclude` wrapper divergence) — so assertions use DESCENDANT + * selectors (`root.querySelector(...)`), never direct-child selectors. + * + * Error contract: render failures (throwing controller / template fn) route + * via `$exceptionHandler('$compile')` — the default handler is + * `consoleErrorExceptionHandler`, so spying `console.error` is the observable + * proxy (the `select.test.ts` precedent); the digest never crashes. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Directive } from '@compiler/directive-types'; +import type { ControllerInvokable } from '@controller/index'; +import type { Scope } from '@core/index'; +import { ngModule } from '@core/ng-module'; +import { createInjector } from '@di/injector'; +import { createModule, resetRegistry } from '@di/module'; +import type { LocationService } from '@location/index'; +import { $RouteProvider, ngRoute, type RouteParams } from '@route/index'; + +interface ViewHarness { + root: HTMLElement; + $rootScope: Scope; + $location: LocationService; +} + +interface BootOptions { + /** Page markup compiled into the harness root (defaults to the element form ``). */ + html?: string; + /** Controllers registered by NAME on the app module via the `.controller` module-DSL sugar (spec 021). */ + controllers?: Record; +} + +/** + * Register an `'app'` module whose config block receives `$routeProvider`, + * build the injector, compile + link a root carrying an `ng-view` marker, and + * return the harness. Deps stay `[]` — the module OBJECTS are passed directly + * so a neighbouring `resetRegistry()` cannot evict them (the `route.test.ts` + * precedent). Linking runs `ngView`'s initial `update()` against an + * as-yet-unresolved `$route.current` — the slot starts empty. + */ +function bootView(configure: (routeProvider: $RouteProvider) => void, options: BootOptions = {}): ViewHarness { + const app = createModule('app', []).config(['$routeProvider', configure]); + for (const [name, invokable] of Object.entries(options.controllers ?? {})) { + app.controller(name, invokable); + } + const injector = createInjector([ngModule, ngRoute, app]); + window.location.hash = ''; + const $rootScope = injector.get('$rootScope'); + const $location = injector.get('$location'); + const root = document.createElement('div'); + root.innerHTML = options.html ?? ''; + injector.get('$compile')(root)($rootScope); + return { root, $rootScope, $location }; +} + +/** Navigate and complete the (fully synchronous this slice) pipeline in one digest. */ +function navigate(harness: ViewHarness, path: string): void { + harness.$location.path(path); + harness.$rootScope.$digest(); +} + +/** The `transclude: 'element'` Comment placeholder survives every render / teardown. */ +function hasViewPlaceholder(root: HTMLElement): boolean { + return Array.from(root.childNodes).some((node) => node.nodeType === Node.COMMENT_NODE); +} + +/** + * `Scope` exposes no public `$$destroyed` flag — `$$watchers === null` is the + * observable signal that `$destroy()` has run (the `transclude-cleanup.test.ts` + * precedent). + */ +function isDestroyed(scope: Scope): boolean { + return (scope as unknown as { $$watchers: unknown }).$$watchers === null; +} + +afterEach(() => { + resetRegistry(); + // Keep the shared jsdom URL clean for neighbouring tests — the digest + // flushes `$location` mutations into the real `window.location.hash`. + window.location.hash = ''; +}); + +// ──────────────────────────────────────────────────────────────────────────── +// registration on the ngRoute module +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngView — registration', () => { + it("is DI-resolvable as injector.get('ngViewDirective') — one ECA / element-transclude DDO", () => { + const app = createModule('app', []).config(['$routeProvider', () => undefined]); + const injector = createInjector([ngModule, ngRoute, app]); + const directives = injector.get('ngViewDirective'); + expect(directives).toHaveLength(1); + expect(directives[0]?.restrict).toBe('ECA'); + expect(directives[0]?.terminal).toBe(true); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R5 — the view placeholder +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngView — R5 view placeholder basics', () => { + it('leaves the slot empty before any route resolves (placeholder Comment only)', () => { + const harness = bootView((p) => { + p.when('/home', { template: '

Home

' }); + }); + // Link ran the initial update() against an undefined $route.current. + expect(hasViewPlaceholder(harness.root)).toBe(true); + expect(harness.root.children).toHaveLength(0); + expect(harness.root.textContent).toBe(''); + + // A digest on a non-matching URL (no otherwise) still renders nothing. + harness.$rootScope.$digest(); + expect(harness.root.children).toHaveLength(0); + }); + + it('renders the active route template into the wrapper sibling of the placeholder', () => { + const harness = bootView((p) => { + p.when('/home', { template: '

Hello route

' }); + }); + navigate(harness, '/home'); + expect(harness.root.querySelector('.msg')?.textContent).toBe('Hello route'); + // One wrapper
next to the surviving Comment placeholder. + expect(harness.root.children).toHaveLength(1); + expect(hasViewPlaceholder(harness.root)).toBe(true); + }); + + it('bindings inside the template stay live — a later digest re-renders inherited values', () => { + const harness = bootView((p) => { + p.when('/live', { template: '

{{banner}}

' }); + }); + navigate(harness, '/live'); + expect(harness.root.querySelector('.msg')?.textContent).toBe(''); + + harness.$rootScope.banner = 'Updated'; + harness.$rootScope.$digest(); + expect(harness.root.querySelector('.msg')?.textContent).toBe('Updated'); + }); + + it('navigating to a second route swaps content — old wrapper gone, exactly one wrapper remains', () => { + const harness = bootView((p) => { + p.when('/a', { template: '

A

' }).when('/b', { template: '

B

' }); + }); + navigate(harness, '/a'); + const firstWrapper = harness.root.children[0]; + expect(harness.root.querySelector('.a')).not.toBeNull(); + + navigate(harness, '/b'); + expect(harness.root.querySelector('.a')).toBeNull(); + expect(harness.root.querySelector('.b')?.textContent).toBe('B'); + // Two navigations → exactly one wrapper; the old one is fully detached. + expect(harness.root.children).toHaveLength(1); + expect(harness.root.children[0]).not.toBe(firstWrapper); + expect(firstWrapper?.isConnected).toBe(false); + }); + + it("destroys the previous screen's child scope on navigation (bindings stop)", () => { + let captured: Scope | undefined; + const harness = bootView((p) => { + p.when('/a', { + template: '

A

', + controller: [ + '$scope', + ($scope: Scope) => { + captured = $scope; + }, + ], + }).when('/b', { template: '

B

' }); + }); + navigate(harness, '/a'); + if (captured === undefined) { + throw new Error('route controller never ran'); + } + expect(isDestroyed(captured)).toBe(false); + + navigate(harness, '/b'); + expect(isDestroyed(captured)).toBe(true); + }); + + it('a route with NO template leaves the slot empty (previous screen still torn down)', () => { + const harness = bootView((p) => { + p.when('/a', { template: '

A

' }).when('/none', {}); + }); + navigate(harness, '/a'); + expect(harness.root.querySelector('.a')).not.toBeNull(); + + navigate(harness, '/none'); + expect(harness.root.children).toHaveLength(0); + expect(hasViewPlaceholder(harness.root)).toBe(true); + }); + + it.each([ + ['element', ''], + ['attribute', '
'], + ['class', '
'], + ])('restrict ECA — the %s form renders', (_label, html) => { + const harness = bootView( + (p) => { + p.when('/home', { template: '

Hi

' }); + }, + { html }, + ); + navigate(harness, '/home'); + expect(harness.root.querySelector('.msg')?.textContent).toBe('Hi'); + }); + + it('an EMPTY-string template still renders (empty wrapper) and still runs the controller', () => { + let ran = 0; + const harness = bootView((p) => { + p.when('/blank', { + template: '', + controller: [ + () => { + ran++; + }, + ], + }); + }); + navigate(harness, '/blank'); + expect(harness.root.children).toHaveLength(1); + expect(harness.root.children[0]?.innerHTML).toBe(''); + expect(ran).toBe(1); + }); + + it('a templateUrl-ONLY route renders nothing this slice — silently, no error', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const harness = bootView((p) => { + p.when('/remote', { templateUrl: '/screens/remote.html' }); + }); + navigate(harness, '/remote'); + expect(harness.root.children).toHaveLength(0); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R7 — per-route controller +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngView — R7 per-route controller', () => { + it('an array-annotated controller runs with the NEW child $scope — its writes render on first paint', () => { + // The template interpolates a value the controller sets on $scope; it + // appearing on the FIRST render is also the observable proof that the + // controller is instantiated BEFORE the template's children link. + const harness = bootView((p) => { + p.when('/home', { + template: '

{{msg}}

', + controller: [ + '$scope', + ($scope: Scope) => { + $scope.msg = 'from ctrl'; + }, + ], + }); + }); + navigate(harness, '/home'); + expect(harness.root.querySelector('.msg')?.textContent).toBe('from ctrl'); + }); + + it('controllerAs publishes the instance under the alias for template bindings', () => { + const harness = bootView((p) => { + p.when('/home', { + template: '

{{vm.x}}

', + controller: [ + function (this: { x?: string }) { + this.x = 'via alias'; + }, + ], + controllerAs: 'vm', + }); + }); + navigate(harness, '/home'); + expect(harness.root.querySelector('.msg')?.textContent).toBe('via alias'); + }); + + it("a registered 'Ctrl as vm' name suffix publishes the alias when controllerAs is absent", () => { + const greetCtrl: ControllerInvokable = [ + function (this: { x?: string }) { + this.x = 'aliased'; + }, + ]; + const harness = bootView( + (p) => { + p.when('/greet', { template: '

{{vm.x}}

', controller: 'GreetCtrl as vm' }); + }, + { controllers: { GreetCtrl: greetCtrl } }, + ); + navigate(harness, '/greet'); + expect(harness.root.querySelector('.msg')?.textContent).toBe('aliased'); + }); + + it('constructs a FRESH controller instance on each activation (away and back → twice)', () => { + let constructed = 0; + const harness = bootView((p) => { + p.when('/counted', { + template: '

counted

', + controller: [ + () => { + constructed++; + }, + ], + }).when('/other', { template: '

other

' }); + }); + navigate(harness, '/counted'); + expect(constructed).toBe(1); + navigate(harness, '/other'); + expect(constructed).toBe(1); + navigate(harness, '/counted'); + expect(constructed).toBe(2); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// $viewContentLoaded +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngView — $viewContentLoaded', () => { + it('fires AFTER DOM insertion, reaches $rootScope via $emit, once per navigation', () => { + const seen: string[] = []; + const harness = bootView((p) => { + p.when('/a', { template: '

A

' }).when('/b', { template: '

B

' }); + }); + harness.$rootScope.$on('$viewContentLoaded', () => { + // The rendered screen is ALREADY in the live DOM inside the listener. + seen.push(harness.root.querySelector('.msg')?.textContent ?? '(missing)'); + }); + + navigate(harness, '/a'); + navigate(harness, '/b'); + expect(seen).toEqual(['A', 'B']); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// function-form template +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngView — function-form template', () => { + it('is called with $route.current.params (upstream getTemplateFor parity)', () => { + const harness = bootView((p) => { + p.when('/users/:id', { + template: (params: RouteParams) => `

User ${String(params.id)}

`, + }); + }); + navigate(harness, '/users/42'); + expect(harness.root.querySelector('.who')?.textContent).toBe('User 42'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// teardown edges +// ──────────────────────────────────────────────────────────────────────────── + +describe('ngView — teardown edges', () => { + it('navigating to a no-match URL (no otherwise) clears the view', () => { + const harness = bootView((p) => { + p.when('/a', { template: '

A

' }); + }); + navigate(harness, '/a'); + expect(harness.root.querySelector('.a')).not.toBeNull(); + + navigate(harness, '/nowhere'); + expect(harness.root.children).toHaveLength(0); + expect(harness.root.textContent).toBe(''); + expect(hasViewPlaceholder(harness.root)).toBe(true); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// error routing — '$compile' via the default console.error handler +// ──────────────────────────────────────────────────────────────────────────── + +describe("ngView — render errors route via $exceptionHandler('$compile')", () => { + it('a throwing controller reports via console.error, leaves the slot empty, and navigation keeps working', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const harness = bootView((p) => { + p.when('/bad', { + template: '

never shown

', + controller: [ + () => { + throw new Error('ctrl boom'); + }, + ], + }).when('/ok', { template: '

recovered

' }); + }); + + expect(() => { + navigate(harness, '/bad'); + }).not.toThrow(); + expect(consoleSpy).toHaveBeenCalled(); + // The failure happened before mount — the container was never inserted. + expect(harness.root.children).toHaveLength(0); + expect(harness.root.querySelector('.msg')).toBeNull(); + + // The digest survived: a subsequent navigation renders normally. + navigate(harness, '/ok'); + expect(harness.root.querySelector('.msg')?.textContent).toBe('recovered'); + consoleSpy.mockRestore(); + }); + + it('a throwing template FUNCTION reports via console.error and does not crash the digest', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const harness = bootView((p) => { + p.when('/bad', { + template: () => { + throw new Error('tpl boom'); + }, + }).when('/ok', { template: '

still fine

' }); + }); + + expect(() => { + navigate(harness, '/bad'); + }).not.toThrow(); + expect(consoleSpy).toHaveBeenCalled(); + expect(harness.root.children).toHaveLength(0); + + navigate(harness, '/ok'); + expect(harness.root.querySelector('.msg')?.textContent).toBe('still fine'); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/route/ng-route-module.ts b/src/route/ng-route-module.ts index c225dc2..19fa13a 100644 --- a/src/route/ng-route-module.ts +++ b/src/route/ng-route-module.ts @@ -22,6 +22,13 @@ * `$route` repopulates IN PLACE on each navigation (upstream * `$RouteParamsProvider` pattern) — injectable on its own so controllers * read live URL values without depending on `$route`. + * - `ngView` (directive, spec 040 Slice 4): the route-view placeholder — + * registered via the module DSL's single-name `.directive(...)` (pure + * config-block sugar forwarding to `$compileProvider.directive`, spec + * 021), which also widens the typed registry with + * `ngViewDirective: Directive[]` automatically. DI-only (reachable via + * `injector.get('ngViewDirective')`), NOT exported from the `@route` + * barrel — the core built-ins precedent. * * `$route`'s `$get` injects `$rootScope` / `$location` from the core `ng` * module — resolved at run time through the injector, so `ngRoute` itself @@ -37,6 +44,7 @@ */ import { createModule } from '@di/module'; +import { ngViewDirective, NG_VIEW_NAME } from '@route/ng-view'; import { $RouteProvider } from '@route/route-provider'; import type { RouteParams, RouteService } from '@route/route-types'; @@ -56,4 +64,5 @@ declare module '@di/di-types' { export const ngRoute = createModule('ngRoute', []) .provider('$route', $RouteProvider) - .factory('$routeParams', [(): RouteParams => ({})]); + .factory('$routeParams', [(): RouteParams => ({})]) + .directive(NG_VIEW_NAME, ngViewDirective); diff --git a/src/route/ng-view.ts b/src/route/ng-view.ts new file mode 100644 index 0000000..ab0cc44 --- /dev/null +++ b/src/route/ng-view.ts @@ -0,0 +1,353 @@ +/** + * `ngView` — the route-view placeholder directive (spec 040 Slice 4; + * FS R5 view placeholder, R7 per-route controller). + * + * `` / `
` / `
` + * marks the single spot in the page where the ACTIVE route's screen + * renders. On every `$routeChangeSuccess` broadcast (and once at link + * time, covering a route that resolved BEFORE `ngView` linked), the + * directive tears down the previous screen and renders + * `$route.current`'s template into a fresh wrapper `
` inserted + * after its Comment placeholder, against a fresh child scope, with the + * route's controller instantiated (R7). + * + * **Structural clone of `ngInclude`.** Same DDO (`restrict: 'ECA'`, + * `priority: 400`, `terminal: true`, `transclude: 'element'`), same + * Comment-placeholder mechanics (the spec 027 Slice 2 foundation swaps + * the host for `` at compile time and hands the link + * fn the Comment), same wrapper-`
` container insertion, and the + * same teardown discipline. The directive never uses `$transclude` + * itself — it REPLACES the slot with the route template; the original + * host's children are irrelevant. + * + * **This slice renders INLINE templates only.** `RouteDefinition.template` + * may be a string (used verbatim) or a `(routeParams) => string` + * function (called with `$route.current.params` — upstream + * `getTemplateFor` parity). A route carrying ONLY `templateUrl` renders + * NOTHING this slice — silently, no error. + * TODO(slice-5): `templateUrl` fetching via `$templateRequest` (plus the + * stale-fetch token sentinel `ngInclude` carries — unnecessary here + * while rendering is fully synchronous). + * TODO(slice-6): `resolve` locals — spread `$route.current.locals` into + * the controller locals and publish them on the child scope under + * `resolveAs ?? '$resolve'`. + * + * **Render order (pinned, upstream `ngViewFillContent` parity):** + * 1. Tear down the previous clone (`currentScope.$destroy()` BEFORE + * DOM removal — the `ngInclude` / `ngIf` order). + * 2. Parse the template into a fresh wrapper `
` container. + * 3. `newScope = scope.$new()` (a child of the surrounding scope). + * 4. `$compile(container)` → linker (compile BEFORE controller + * instantiation). + * 5. Instantiate the route controller with locals `{ $scope: newScope }` + * and `ident = current.controllerAs` (the 1–3 arg `$controller` + * path publishes the alias on the scope internally; a + * `'Ctrl as vm'` name suffix is handled by `parseControllerName` + * when no explicit `controllerAs` is set). Stash the instance on + * the container under `'ngController'` so descendants using + * `require: '^ngController'` resolve it — mirrors upstream's + * `$element.data('$ngControllerController', controller)`. + * 6. `linker(newScope)` — children link AFTER the controller exists. + * 7. Insert the container after the placeholder Comment. + * 8. `newScope.$emit('$viewContentLoaded')` — emitted on the NEW + * scope AFTER linking + insertion (upstream order), so listeners + * reading the DOM see the rendered screen. + * + * **Dual cleanup registration — BOTH are load-bearing** (the `ngInclude` + * analysis): `addElementCleanup(placeholder, …)` covers an ancestor + * structural directive walking its cleanup queue (Comment placeholders + * have no `children` HTMLCollection for `destroyElementScope` to walk), + * and `scope.$on('$destroy', …)` covers a plain surrounding-scope + * teardown that never touches the placeholder's queue. + * + * **No `$route.current` / no template → the slot is left EMPTY** (R2 + * acceptance): the teardown still runs, nothing is rendered. + * + * **Errors.** No new error classes, no new `EXCEPTION_HANDLER_CAUSES` + * token — the tuple stays at 13. A throw anywhere in the render body + * (template-fn throw, malformed template compile, controller + * instantiation failure such as `UnknownControllerError`) is caught, + * routed via `invokeExceptionHandler($exceptionHandler, err, '$compile')` + * (the `ngInclude` precedent), and the half-mounted state is torn down + * — the slot ends up empty, the page keeps digesting. + * + * **Wrapper-`
` CSS caveat (inherited from `ngInclude`).** The + * rendered template's nodes live inside a wrapper `
` inserted as + * the placeholder's sibling — NOT inline as direct siblings (a + * documented divergence from AngularJS 1.x). Consumer CSS using + * direct-child selectors against a parent of `ng-view` may need + * descendant selectors instead. + * + * **`autoscroll` / `$anchorScroll` support is out of scope** (tech spec + * §2.5 noted deferral — no `$anchorScroll` service ships). + * + * **Module boundary.** `src/route` importing `@compiler/*` deep paths + * (`cleanup`, `directive-types`, `element-slots`, `node-guards`, + * `template-parse`) and `@controller/controller-types` follows the + * `src/forms` precedent (an opt-in DOM module consuming the compiler's + * internals — `src/forms/form.ts` imports `@compiler/element-slots` + * the same way). The `@controller` import is type-only except for the + * injected `$controller` service itself, which arrives via DI. + * + * @example + * ```html + * + * + * + * + * Once a route with `template: '

Home

'` activates: + * + *

Home

+ * --> + * ``` + */ + +import { addElementCleanup } from '@compiler/cleanup'; +import type { CompileService, DirectiveFactory, DirectiveFactoryReturn, LinkFn } from '@compiler/directive-types'; +import { stashController } from '@compiler/element-slots'; +import { isComment } from '@compiler/node-guards'; +import { parseTemplate } from '@compiler/template-parse'; +import type { ControllerInvokable, ControllerLocals, ControllerService } from '@controller/controller-types'; +import type { Scope } from '@core/index'; +import { invokeExceptionHandler, type ExceptionHandler } from '@exception-handler/index'; + +import type { Route, RouteService } from './route-types'; + +/** + * Normalized directive name. Consumed by the `ngRoute` module's + * `.directive(NG_VIEW_NAME, ngViewDirective)` registration + * (`ng-route-module.ts`). The element form ``, the attribute + * form `ng-view`, and the class form `class="ng-view"` all normalize to + * this same name, so a single registration covers `restrict: 'ECA'`. + */ +export const NG_VIEW_NAME = 'ngView'; + +/** + * The `$$ngControllers` stash key the route controller publishes under + * on the wrapper container — `'ngController'`, so template descendants + * using `require: '^ngController'` resolve the route controller exactly + * as they would inside an `ng-controller` region. Mirrors upstream + * ngView's `$element.data('$ngControllerController', controller)`. + */ +const CONTROLLER_STASH_KEY = 'ngController'; + +/** + * Resolve the route's INLINE template to a string, or `null` when the + * route carries no renderable inline template this slice. + * + * - string → used verbatim (an EMPTY string still renders: the screen + * is empty but the controller runs — upstream parity). + * - function → called with `current.params` (upstream `getTemplateFor` + * passes the route params). A non-string return is treated as "no + * template" defensively; a throw propagates to the caller's + * try/catch → `'$compile'`. + * - absent → `null`. TODO(slice-5): fall back to `templateUrl` here + * (string or `(params) => string`) via `$templateRequest`. + */ +function resolveTemplateString(current: Route): string | null { + const { template } = current; + if (typeof template === 'string') { + return template; + } + if (typeof template === 'function') { + // Widen to `unknown` before the string check: the declared return + // type is `string`, but the function comes from consumer-authored + // route definitions (the `RouteDefinition` index signature admits + // arbitrary shapes) — trust but verify. + const produced: unknown = template(current.params); + return typeof produced === 'string' ? produced : null; + } + return null; +} + +function ngViewFactory( + $route: RouteService, + $compile: CompileService, + $controller: ControllerService, + $exceptionHandler: ExceptionHandler, +): DirectiveFactoryReturn { + const link: LinkFn = (scope, element) => { + // The runtime `element` is the Comment placeholder the + // `transclude: 'element'` foundation installed in place of the host + // element. The public LinkFn types it as `Element`; verify with the + // shared guard rather than casting (the ngInclude precedent). + if (!isComment(element)) { + throw new Error(`ngView: expected placeholder to be a Comment, got nodeType ${String(element.nodeType)}`); + } + const placeholder = element; + + // Closure-local state for the currently-mounted screen. Both fields + // mutate in lock-step: set on successful render, reset to null by + // `clearCurrentClone()`. + let currentClone: Element | null = null; + let currentScope: Scope | null = null; + + /** + * Tear down the currently-mounted screen (if any). Destroys the + * child scope BEFORE detaching from the DOM so `$on('$destroy', …)` + * listeners that read DOM state still observe the live tree + * (mirrors `ngInclude` / `ngIf`). Idempotent. + */ + const clearCurrentClone = () => { + if (currentScope !== null) { + currentScope.$destroy(); + } + if (currentClone !== null) { + currentClone.remove(); + } + currentClone = null; + currentScope = null; + }; + + // Dual cleanup registration — BOTH load-bearing (see file TSDoc): + // the placeholder's cleanup queue covers ancestor structural + // teardown (Comments have no children for parent cleanup walks); + // the scope $destroy listener covers a plain scope teardown that + // never reaches the placeholder's queue. + addElementCleanup(placeholder, () => { + clearCurrentClone(); + }); + scope.$on('$destroy', clearCurrentClone); + + /** + * The render pipeline — runs at link time and on every + * `$routeChangeSuccess`. See the file TSDoc for the pinned order. + */ + const update = () => { + // Tear down the previous screen FIRST — even when nothing new + // renders (no current route / no inline template), the previous + // screen must go: R5 acceptance "the previous screen is fully + // removed", R2 acceptance "left empty". + clearCurrentClone(); + + const current = $route.current; + if (current === undefined) { + return; + } + + // Track the fresh child scope separately from `currentScope` so + // the catch block can distinguish "failed before mount" (destroy + // the orphan scope directly) from "failed after mount" (run the + // full teardown). + let newScope: Scope | null = null; + try { + const html = resolveTemplateString(current); + if (html === null) { + // No inline template — render nothing, silently. + // TODO(slice-5): a `templateUrl`-only route fetches here. + return; + } + + // Fresh wrapper container (the ngInclude wrapper-
shape — + // one Element for $compile to walk, one `remove()` on teardown + // no matter how many top-level nodes the template has). + const parsedNodes = parseTemplate(html); + const container = document.createElement('div'); + for (const tplNode of parsedNodes) { + container.appendChild(tplNode); + } + + // Fresh child scope for the screen — a child of the + // surrounding scope, not an isolate (FS R5: the screen behaves + // like any other part of the app). + newScope = scope.$new(); + + // Compile BEFORE controller instantiation; link AFTER — the + // upstream ngViewFillContent order, so template descendants' + // link phase (and `require: '^ngController'` walks) see the + // stashed route controller. + const linker = $compile(container); + + if (current.controller !== undefined) { + // TODO(slice-6): spread `current.locals` (resolved `resolve` + // values) into the locals map ahead of `$scope`. + const locals: ControllerLocals = { $scope: newScope }; + // `RouteDefinition.controller` is typed with @di's `Invokable` + // (author-friendly — typed-param controller fns are + // assignable); `$controller` accepts the equivalent + // `ControllerInvokable` encoding. Both describe the same + // runtime shapes (a bare fn or the array-annotated form) — + // the assertion bridges the structural-variance difference + // without changing behavior. + const controllerExpr = current.controller as string | ControllerInvokable; + // The 1–3 arg `$controller` path publishes the alias on + // `locals.$scope` internally: the explicit `ident` + // (`current.controllerAs`) wins; absent that, a + // `'Ctrl as vm'` name suffix is parsed by + // `parseControllerName`. + const instance = $controller(controllerExpr, locals, current.controllerAs); + stashController(container, CONTROLLER_STASH_KEY, instance); + } + + linker(newScope); + + // Insert the wrapper (with its compiled + linked children) as + // the placeholder's next sibling, then publish the mount. + placeholder.parentNode?.insertBefore(container, placeholder.nextSibling); + currentClone = container; + currentScope = newScope; + + // Emitted on the NEW scope AFTER linking + insertion (upstream + // order) — listeners reading the DOM see the rendered screen. + newScope.$emit('$viewContentLoaded'); + } catch (err: unknown) { + invokeExceptionHandler($exceptionHandler, err, '$compile'); + if (currentScope !== null) { + // Failed after mount (hypothetical — nothing throws past the + // mount today, but keep the teardown total): full clear. + clearCurrentClone(); + } else if (newScope !== null) { + // Failed before mount: destroy the orphan child scope so its + // watchers detach; the container (if built) was never + // inserted and goes to GC on its own. + newScope.$destroy(); + } + } + }; + + // Re-render on every completed navigation. `$routeChangeSuccess` + // is broadcast from $rootScope, so it reaches this scope; the $on + // registration auto-deregisters when `scope` is destroyed. + scope.$on('$routeChangeSuccess', () => { + update(); + }); + + // Initial render — covers the route that resolved BEFORE ngView + // linked (the `$routeChangeSuccess` for it already fired). + update(); + }; + + return { + restrict: 'ECA', + priority: 400, + terminal: true, + transclude: 'element', + link, + }; +} + +/** + * DI-annotated factory ready for + * `.directive('ngView', ngViewDirective)` on the `ngRoute` module. The + * factory injects `$route` (the current-route source — injecting it + * here also forces `$route` instantiation, wiring its + * `$locationChangeSuccess` listener the moment a page contains an + * `ng-view`, upstream parity), `$compile` (template compilation), + * `$controller` (per-route controller instantiation), and + * `$exceptionHandler` (error routing). + * + * Deviation from tech spec §2.5's suggested list: `$injector` is NOT + * injected — the lazy `$sce` probe it served in `ngInclude` guards + * remote-URL fetching, which this slice does not do. + * TODO(slice-5): revisit when `templateUrl` fetching lands + * (`$templateRequest` handles trust itself via the `$sce`-aware + * template pipeline). + */ +export const ngViewDirective: DirectiveFactory = [ + '$route', + '$compile', + '$controller', + '$exceptionHandler', + ngViewFactory, +]; From ecbdd36bb510cf7e8b411317e3908893db1db180 Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Mon, 6 Jul 2026 22:47:23 -0400 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20remote=20route=20templates=20?= =?UTF-8?q?=E2=80=94=20templateUrl=20via=20$templateRequest,=20uniform=20l?= =?UTF-8?q?ocals.$template=20(spec=20040=20slice=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- context/spec/040-routing/tasks.md | 4 +- src/route/__tests__/ng-view.test.ts | 5 +- .../__tests__/route-template-url.test.ts | 405 ++++++++++++++++++ src/route/ng-view.ts | 53 ++- src/route/route-provider.ts | 25 +- src/route/route-template.ts | 94 ++++ src/route/route-types.ts | 14 +- src/route/route.ts | 155 ++++++- 8 files changed, 708 insertions(+), 47 deletions(-) create mode 100644 src/route/__tests__/route-template-url.test.ts create mode 100644 src/route/route-template.ts diff --git a/context/spec/040-routing/tasks.md b/context/spec/040-routing/tasks.md index 437f758..10a05b6 100644 --- a/context/spec/040-routing/tasks.md +++ b/context/spec/040-routing/tasks.md @@ -37,8 +37,8 @@ Each slice keeps the library in a runnable, green state (`pnpm typecheck && pnpm ## Slice 5: Remote templates (`templateUrl`) -- [ ] `$route` template resolution via `$templateRequest` (`templateUrl` string + fn, `template` fn), cache reuse; `ngView` renders fetched template. **[Agent: typescript-framework]** -- [ ] Verify: jsdom with mock `$templateRequest` — async flush (`$digest()` + `await Promise.resolve()`), cache-reuse, fn forms. **[Agent: vitest-testing]** +- [x] `$route` template resolution via `$templateRequest` (`templateUrl` string + fn, `template` fn), cache reuse; `ngView` renders fetched template. **[Agent: typescript-framework]** +- [x] Verify: jsdom with mock `$templateRequest` — async flush (`$digest()` + `await Promise.resolve()`), cache-reuse, fn forms. **[Agent: vitest-testing]** ## Slice 6: `resolve` + error handling diff --git a/src/route/__tests__/ng-view.test.ts b/src/route/__tests__/ng-view.test.ts index 5fb7816..6359337 100644 --- a/src/route/__tests__/ng-view.test.ts +++ b/src/route/__tests__/ng-view.test.ts @@ -243,7 +243,10 @@ describe('ngView — R5 view placeholder basics', () => { expect(ran).toBe(1); }); - it('a templateUrl-ONLY route renders nothing this slice — silently, no error', () => { + it('a templateUrl route renders nothing BEFORE the fetch resolves — silently, no error', () => { + // Slice 5 made templateUrl routes commit asynchronously: at this point + // the fetch is merely IN FLIGHT, so nothing is committed or rendered yet + // (the async leg is covered end-to-end in route-template-url.test.ts). const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); const harness = bootView((p) => { p.when('/remote', { templateUrl: '/screens/remote.html' }); diff --git a/src/route/__tests__/route-template-url.test.ts b/src/route/__tests__/route-template-url.test.ts new file mode 100644 index 0000000..2a61368 --- /dev/null +++ b/src/route/__tests__/route-template-url.test.ts @@ -0,0 +1,405 @@ +/** + * `$route` remote-template (`templateUrl`) tests via DI (spec 040 Slice 5; + * FS R6). + * + * Drive pattern (extends the Slice-3/4 `route.test.ts` / `ng-view.test.ts` + * contracts with an ASYNC leg): + * 1. `createInjector([ngModule, ngRoute, app])` — the app module's config + * block receives `$routeProvider`; when a mock fetch seam is needed the + * app module RE-REGISTERS `$templateRequest` (last-wins) as the REAL + * `createTemplateRequest({ cache, fetcher })` closure over the injector's + * own `$templateCache` — the `ng-include.test.ts` mock-fetcher precedent. + * Driving the real service keeps cache-first + in-flight dedup in play. + * 2. Compile + link a root carrying `` so commits render end-to-end. + * 3. Navigate: `$location.path('/x'); $rootScope.$digest()` — Start fires and + * the fetch begins IN FLIGHT; nothing commits, nothing renders yet. + * 4. Flush (the verified recipe): microtask awaits let the native fetcher + * promise settle, the `$templateRequest` cache-write `.then` run, and the + * `$q.when` adoption queue its `$evalAsync`; the closing + * `$rootScope.$digest()` drains the continuation — commit → + * `$routeChangeSuccess` → `ngView` render, all inside that digest turn. + * + * Pinned Slice-5 contracts: + * - SYNC FAST PATH unchanged: inline `template` (string / fn) or no template + * commits synchronously same-digest; inline WINS over `templateUrl` (the + * URL is never fetched when both are present). + * - LOCALS: a committed route ALWAYS carries a `locals` object; + * `locals.$template` is present ONLY when template text resolved (inline + * string, inline fn return, or the fetched body). + * - FETCH FAILURE: `$routeChangeError(next, previous, rejection)` broadcast, + * NO commit, previous view intact — NOT routed via `$exceptionHandler` (no + * `console.error`; the error event IS the channel, upstream parity). + * - A THROWING `templateUrl` FN → `$routeChangeError` synchronously (no + * fetch, no commit). + * - STALENESS: the closure navigation token is bumped per non-vetoed pass; + * an async settle whose captured token lost the race is dropped silently + * (no events, no state writes, no DOM). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Scope, ScopeEvent } from '@core/index'; +import { ngModule } from '@core/ng-module'; +import { createInjector } from '@di/injector'; +import { createModule, resetRegistry } from '@di/module'; +import type { LocationService } from '@location/index'; +import { $RouteProvider, ngRoute, type Route, type RouteParams, type RouteService } from '@route/index'; +import { createTemplateRequest } from '@template/template-request'; +import type { TemplateCacheService, TemplateFetcher, TemplateRequestFn } from '@template/template-types'; + +interface Harness { + root: HTMLElement; + $route: RouteService; + $rootScope: Scope; + $location: LocationService; +} + +/** + * Register an `'app'` module whose config block receives `$routeProvider`, + * optionally override `$templateRequest` with the REAL service wired to a + * mock fetch seam (last-wins `.factory`, the `ng-include.test.ts` pattern), + * build the injector, and compile + link a root carrying ``. + * Deps stay `[]` — the module OBJECTS are passed directly so a neighbouring + * `resetRegistry()` cannot evict them (the `route.test.ts` precedent). + */ +function boot(configure: (routeProvider: $RouteProvider) => void, fetcher?: TemplateFetcher): Harness { + const app = createModule('app', []).config(['$routeProvider', configure]); + if (fetcher !== undefined) { + // Last-wins override: the injector hands this closure out to every + // `$templateRequest`-injecting consumer — including `$route`'s `$get`. + // Using the REAL `createTemplateRequest` keeps the cache-first branch + // and in-flight dedup in play; only the network seam is mocked. + app.factory('$templateRequest', [ + '$templateCache', + (cache: TemplateCacheService): TemplateRequestFn => createTemplateRequest({ cache, fetcher }), + ]); + } + const injector = createInjector([ngModule, ngRoute, app]); + window.location.hash = ''; + const $route = injector.get('$route'); + const $rootScope = injector.get('$rootScope'); + const $location = injector.get('$location'); + const root = document.createElement('div'); + root.innerHTML = ''; + injector.get('$compile')(root)($rootScope); + return { root, $route, $rootScope, $location }; +} + +/** + * Start a navigation: sync `$location` and run the pipeline up to the Start + * broadcast. For an inline / template-less route this COMPLETES the commit + * (sync fast path); for a `templateUrl` route the fetch is now IN FLIGHT. + */ +function navigate(harness: Harness, path: string): void { + harness.$location.path(path); + harness.$rootScope.$digest(); +} + +/** + * The verified async-commit flush recipe: microtask awaits settle the native + * fetcher promise → `$templateRequest`'s cache-write `.then` → the `$q.when` + * adoption queues its `$evalAsync`; the closing digest drains the commit → + * Success → `ngView` render. Three awaits are defensive (the + * `ng-include.test.ts` 3x-flush precedent). + */ +async function flushFetch(harness: Harness): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + harness.$rootScope.$digest(); +} + +interface RecordedRouteEvent { + name: string; + next: Route | undefined; + previous: Route | undefined; + rejection: unknown; +} + +/** Record every Start / Success / Error broadcast in fire order. */ +function recordRouteEvents($rootScope: Scope): RecordedRouteEvent[] { + const events: RecordedRouteEvent[] = []; + const listener = (event: ScopeEvent, ...args: unknown[]): void => { + events.push({ + name: event.name, + next: args[0] as Route | undefined, + previous: args[1] as Route | undefined, + rejection: args[2], + }); + }; + $rootScope.$on('$routeChangeStart', listener); + $rootScope.$on('$routeChangeSuccess', listener); + $rootScope.$on('$routeChangeError', listener); + return events; +} + +afterEach(() => { + resetRegistry(); + // Keep the shared jsdom URL clean for neighbouring tests — the digest + // flushes `$location` mutations into the real `window.location.hash`. + window.location.hash = ''; +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R6 — templateUrl string route, end-to-end +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — templateUrl (R6) end-to-end', () => { + it('Start fires with the fetch in flight; the commit + render + Success land only after the flush', async () => { + const fetcher = vi.fn(() => Promise.resolve('

Fetched!

')); + const harness = boot((p) => { + p.when('/remote', { templateUrl: '/screens/remote.html' }); + }, fetcher); + const events = recordRouteEvents(harness.$rootScope); + + navigate(harness, '/remote'); + // Start fired; the fetch is in flight — NOTHING committed or rendered. + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart']); + expect(fetcher).toHaveBeenCalledWith('/screens/remote.html'); + expect(harness.$route.current).toBeUndefined(); + expect(harness.root.children).toHaveLength(0); + expect(harness.root.textContent).toBe(''); + + await flushFetch(harness); + expect(harness.$route.current?.templateUrl).toBe('/screens/remote.html'); + expect(harness.$route.current?.locals?.$template).toBe('

Fetched!

'); + expect(harness.root.querySelector('.remote')?.textContent).toBe('Fetched!'); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + // The Success payload's `next` IS the committed route. + expect(events[1]?.next).toBe(harness.$route.current); + expect(events[1]?.previous).toBeUndefined(); + + // A later digest does NOT re-fire Success — exactly once per navigation. + harness.$rootScope.$digest(); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + }); + + it('the templateUrl FUNCTION form receives next.params and its return URL is fetched', async () => { + const seenParams: RouteParams[] = []; + const fetcher = vi.fn((url) => Promise.resolve(`

${url}

`)); + const harness = boot((p) => { + p.when('/docs/:id', { + templateUrl: (params: RouteParams) => { + seenParams.push({ ...params }); + return `/tpl/doc-${String(params.id)}.html`; + }, + }); + }, fetcher); + + navigate(harness, '/docs/7'); + // The fn ran synchronously at navigation time with the MATCHED params. + expect(seenParams).toEqual([{ id: '7' }]); + expect(fetcher).toHaveBeenCalledWith('/tpl/doc-7.html'); + + await flushFetch(harness); + expect(harness.$route.current?.locals?.$template).toBe('

/tpl/doc-7.html

'); + expect(harness.root.querySelector('.doc')?.textContent).toBe('/tpl/doc-7.html'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// the uniform locals contract +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — the uniform locals contract', () => { + it('an INLINE string route commits with locals.$template === the rendered inline text', () => { + const harness = boot((p) => { + p.when('/home', { template: '

Inline

' }); + }); + navigate(harness, '/home'); + expect(harness.$route.current?.locals?.$template).toBe('

Inline

'); + expect(harness.root.querySelector('.msg')?.textContent).toBe('Inline'); + }); + + it("an INLINE template FUNCTION commits with locals.$template === the fn's return", () => { + const harness = boot((p) => { + p.when('/users/:id', { template: (params: RouteParams) => `

U${String(params.id)}

` }); + }); + navigate(harness, '/users/9'); + expect(harness.$route.current?.locals?.$template).toBe('

U9

'); + expect(harness.root.querySelector('.who')?.textContent).toBe('U9'); + }); + + it('a committed route ALWAYS carries a locals object — no template → locals = {} (no $template key)', () => { + const harness = boot((p) => { + p.when('/none', {}); + }); + navigate(harness, '/none'); + expect(harness.$route.current?.locals).toEqual({}); + expect(harness.$route.current?.locals).not.toHaveProperty('$template'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// inline wins over templateUrl +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — inline template WINS over templateUrl', () => { + it('commits synchronously with the inline text; the fetcher is NEVER called', () => { + const fetcher = vi.fn(() => Promise.resolve('

never

')); + const harness = boot((p) => { + p.when('/both', { template: '

Inline wins

', templateUrl: '/never.html' }); + }, fetcher); + + navigate(harness, '/both'); + // Sync fast path — the commit landed in the SAME digest, no flush needed. + expect(harness.$route.current?.locals?.$template).toBe('

Inline wins

'); + expect(harness.root.querySelector('.inline')?.textContent).toBe('Inline wins'); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// cache reuse via the real $templateRequest +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — template cache reuse (real $templateRequest, mocked fetch seam)', () => { + it('the same URL is fetched ONCE across two activations — away and back serves from $templateCache', async () => { + const fetcher = vi.fn(() => Promise.resolve('

Remote body

')); + const harness = boot((p) => { + p.when('/remote', { templateUrl: '/tpl/cached.html' }).when('/other', { template: '

O

' }); + }, fetcher); + + navigate(harness, '/remote'); + await flushFetch(harness); + expect(harness.root.querySelector('.cached')).not.toBeNull(); + expect(fetcher).toHaveBeenCalledTimes(1); + + navigate(harness, '/other'); + expect(harness.root.querySelector('.other')).not.toBeNull(); + expect(harness.root.querySelector('.cached')).toBeNull(); + + navigate(harness, '/remote'); + // The cache-first branch still resolves asynchronously + // (`Promise.resolve(cached)`) — the same flush applies, but the + // fetcher is NOT consulted again. + await flushFetch(harness); + expect(harness.root.querySelector('.cached')?.textContent).toBe('Remote body'); + expect(harness.$route.current?.locals?.$template).toBe('

Remote body

'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// fetch failure — $routeChangeError, no commit, previous view intact +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — fetch failure (R6 error contract)', () => { + it('broadcasts $routeChangeError(next, previous, rejection); no commit; previous view intact; NO console.error', async () => { + const boom = new Error('fetch boom'); + const fetcher = vi.fn((url) => + url === '/bad.html' ? Promise.reject(boom) : Promise.resolve('

Recovered

'), + ); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const harness = boot((p) => { + p.when('/start', { template: '

Start

' }) + .when('/bad', { templateUrl: '/bad.html' }) + .when('/good', { templateUrl: '/good.html' }); + }, fetcher); + + navigate(harness, '/start'); + const previous = harness.$route.current; + expect(harness.root.querySelector('.start')).not.toBeNull(); + + const events = recordRouteEvents(harness.$rootScope); + navigate(harness, '/bad'); + await flushFetch(harness); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeError']); + const errorEvent = events[1]; + expect(errorEvent?.next?.templateUrl).toBe('/bad.html'); + expect(errorEvent?.previous).toBe(previous); + expect(errorEvent?.rejection).toBe(boom); + // NO commit: current stays previous, the previous screen stays mounted. + expect(harness.$route.current).toBe(previous); + expect(harness.root.querySelector('.start')).not.toBeNull(); + // The error event IS the channel — never $exceptionHandler/console.error. + expect(consoleSpy).not.toHaveBeenCalled(); + + // A subsequent good navigation works normally. + navigate(harness, '/good'); + await flushFetch(harness); + expect(harness.$route.current?.templateUrl).toBe('/good.html'); + expect(harness.root.querySelector('.good')?.textContent).toBe('Recovered'); + expect(events.map((e) => e.name)).toEqual([ + '$routeChangeStart', + '$routeChangeError', + '$routeChangeStart', + '$routeChangeSuccess', + ]); + consoleSpy.mockRestore(); + }); + + it('a THROWING templateUrl FUNCTION broadcasts $routeChangeError SYNCHRONOUSLY — no fetch, no commit', () => { + const boom = new Error('url fn boom'); + const fetcher = vi.fn(() => Promise.resolve('

never

')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const harness = boot((p) => { + p.when('/start', { template: '

S

' }).when('/explode', { + templateUrl: () => { + throw boom; + }, + }); + }, fetcher); + + navigate(harness, '/start'); + const previous = harness.$route.current; + + const events = recordRouteEvents(harness.$rootScope); + // No flush — the throw happens while resolving the URL, same digest. + navigate(harness, '/explode'); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeError']); + expect(events[1]?.rejection).toBe(boom); + expect(events[1]?.previous).toBe(previous); + expect(harness.$route.current).toBe(previous); + expect(harness.root.querySelector('.start')).not.toBeNull(); + expect(fetcher).not.toHaveBeenCalled(); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// staleness — a slow fetch losing to a newer navigation is dropped silently +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — stale-fetch drop', () => { + it('a slow templateUrl settle after a newer inline commit is dropped — no events, no DOM, inline stays current', async () => { + let releaseSlow: ((text: string) => void) | undefined; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + releaseSlow = resolve; + }), + ); + const harness = boot((p) => { + p.when('/slow', { templateUrl: '/slow.html' }).when('/fast', { template: '

Fast

' }); + }, fetcher); + + navigate(harness, '/slow'); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(harness.$route.current).toBeUndefined(); // fetch in flight, nothing committed + + // A NEWER navigation to an inline route commits synchronously and bumps + // the navigation token — the in-flight slow fetch is now stale. + navigate(harness, '/fast'); + const committed = harness.$route.current; + expect(committed?.locals?.$template).toBe('

Fast

'); + expect(harness.root.querySelector('.fast')).not.toBeNull(); + + const events = recordRouteEvents(harness.$rootScope); + if (releaseSlow === undefined) { + throw new Error('the slow fetcher was never invoked'); + } + releaseSlow('

Too late

'); + await flushFetch(harness); + await flushFetch(harness); // defensive second flush — the drop must hold + + // Silent drop: NO Success, NO Error, no state write, no DOM install. + expect(events).toEqual([]); + expect(harness.$route.current).toBe(committed); + expect(harness.root.querySelector('.slow')).toBeNull(); + expect(harness.root.querySelector('.fast')?.textContent).toBe('Fast'); + }); +}); diff --git a/src/route/ng-view.ts b/src/route/ng-view.ts index ab0cc44..389691b 100644 --- a/src/route/ng-view.ts +++ b/src/route/ng-view.ts @@ -20,14 +20,21 @@ * itself — it REPLACES the slot with the route template; the original * host's children are irrelevant. * - * **This slice renders INLINE templates only.** `RouteDefinition.template` - * may be a string (used verbatim) or a `(routeParams) => string` - * function (called with `$route.current.params` — upstream - * `getTemplateFor` parity). A route carrying ONLY `templateUrl` renders - * NOTHING this slice — silently, no error. - * TODO(slice-5): `templateUrl` fetching via `$templateRequest` (plus the - * stale-fetch token sentinel `ngInclude` carries — unnecessary here - * while rendering is fully synchronous). + * **Template source (spec 040 Slice 5 — the uniform `locals.$template` + * contract).** The `$route` pipeline resolves EVERY template — inline + * `template` (string / fn form) AND fetched `templateUrl` — onto + * `current.locals.$template` BEFORE the route commits, so this directive + * renders that slot when present (a committed `templateUrl` route thus + * renders its fetched text). All async work (fetching, staleness + * dropping, `$routeChangeError` on failure) lives in `$route` — this + * directive stays fully synchronous and needs no stale-fetch token of + * its own; it only ever sees COMMITTED routes via `$routeChangeSuccess`. + * When `locals.$template` is absent, {@link resolveTemplateString} falls + * back to resolving the INLINE `template` field directly — covering + * hand-built `$route.current` test doubles and the pinned inline-fn + * throw contract (the fn's throw is caught here and routed via + * `'$compile'` — see `route-template.ts`'s file header). A committed + * route with NO template renders nothing, silently. * TODO(slice-6): `resolve` locals — spread `$route.current.locals` into * the controller locals and publish them on the child scope under * `resolveAs ?? '$resolve'`. @@ -133,17 +140,20 @@ export const NG_VIEW_NAME = 'ngView'; const CONTROLLER_STASH_KEY = 'ngController'; /** - * Resolve the route's INLINE template to a string, or `null` when the - * route carries no renderable inline template this slice. + * FALLBACK inline-template resolution — used only when the committed + * route carries no `locals.$template` (hand-built `$route.current` test + * doubles, or the inline-fn throw/non-string degrade where the `$route` + * pipeline deliberately committed WITHOUT a template — see + * `route-template.ts`). Returns `null` when nothing renderable exists. * * - string → used verbatim (an EMPTY string still renders: the screen * is empty but the controller runs — upstream parity). * - function → called with `current.params` (upstream `getTemplateFor` * passes the route params). A non-string return is treated as "no * template" defensively; a throw propagates to the caller's - * try/catch → `'$compile'`. - * - absent → `null`. TODO(slice-5): fall back to `templateUrl` here - * (string or `(params) => string`) via `$templateRequest`. + * try/catch → `'$compile'` (the pinned Slice-4 error contract). + * - absent → `null` (a `templateUrl` is NEVER fetched here — fetching + * is `$route`'s job, and an uncommitted fetch never reaches ngView). */ function resolveTemplateString(current: Route): string | null { const { template } = current; @@ -232,10 +242,14 @@ function ngViewFactory( // full teardown). let newScope: Scope | null = null; try { - const html = resolveTemplateString(current); + // Uniform contract (Slice 5): a COMMITTED route carries its + // resolved template text — inline OR fetched — on + // `locals.$template`; the inline fallback covers routes without + // `locals` (see resolveTemplateString's TSDoc). + const html = + typeof current.locals?.$template === 'string' ? current.locals.$template : resolveTemplateString(current); if (html === null) { - // No inline template — render nothing, silently. - // TODO(slice-5): a `templateUrl`-only route fetches here. + // No template — render nothing, silently (R2 acceptance). return; } @@ -339,10 +353,9 @@ function ngViewFactory( * * Deviation from tech spec §2.5's suggested list: `$injector` is NOT * injected — the lazy `$sce` probe it served in `ngInclude` guards - * remote-URL fetching, which this slice does not do. - * TODO(slice-5): revisit when `templateUrl` fetching lands - * (`$templateRequest` handles trust itself via the `$sce`-aware - * template pipeline). + * remote-URL fetching, which lives in the `$route` pipeline here + * (`$templateRequest`, injected by `$RouteProvider.$get`), never in + * this directive. */ export const ngViewDirective: DirectiveFactory = [ '$route', diff --git a/src/route/route-provider.ts b/src/route/route-provider.ts index 2032571..7caa515 100644 --- a/src/route/route-provider.ts +++ b/src/route/route-provider.ts @@ -15,10 +15,13 @@ * fallback, stored under the `'null'` key (upstream `routes[null]`). The * string shorthand `otherwise('/home')` normalizes to * `{ redirectTo: '/home' }` (upstream parity). - * - `$get` is `['$rootScope', '$location', '$routeParams', factory]` — the - * exact collaborators the Slice-3 pipeline consumes. `$q` / `$injector` / - * `$templateRequest` are deliberately NOT injected yet; the template + - * `resolve` slice widens the dep list when it adds the async stages. + * - `$get` is `['$rootScope', '$location', '$routeParams', '$q', + * '$templateRequest', factory]` — the collaborators the pipeline + * consumes: the Slice-3 digest trio plus the Slice-5 async-template + * pair (`$q` adopts the fetch promise into the digest, + * `$templateRequest` is the cache-first fetcher). `$injector` is + * deliberately NOT injected yet; the `resolve` slice widens the dep + * list when it adds dependency pre-loading. * * TRAILING-SLASH MECHANISM (documented divergence): upstream `when()` * registers a companion `{ redirectTo }` route per pattern (pattern ± @@ -30,8 +33,10 @@ * match). */ +import type { QService } from '@async/index'; import type { Scope } from '@core/index'; import type { LocationService } from '@location/index'; +import type { TemplateRequestFn } from '@template/index'; import { pathRegExp } from './route-path'; import { createRoute, OTHERWISE_ROUTE_KEY } from './route'; import type { CompiledRouteEntry, RouteDefinition, RouteParams, RouteService } from './route-types'; @@ -95,12 +100,22 @@ export class $RouteProvider { '$rootScope', '$location', '$routeParams', - ($rootScope: Scope, $location: LocationService, $routeParams: RouteParams): RouteService => + '$q', + '$templateRequest', + ( + $rootScope: Scope, + $location: LocationService, + $routeParams: RouteParams, + $q: QService, + $templateRequest: TemplateRequestFn, + ): RouteService => createRoute({ rootScope: $rootScope, location: $location, routeParams: $routeParams, routes: this.$$routes, + q: $q, + templateRequest: $templateRequest, }), ] as const; } diff --git a/src/route/route-template.ts b/src/route/route-template.ts new file mode 100644 index 0000000..e7f8e4e --- /dev/null +++ b/src/route/route-template.ts @@ -0,0 +1,94 @@ +/** + * Route-template resolution helpers (spec 040 Slice 5; FS R6). + * + * Small pure helpers the `$route` pipeline (`route.ts`) uses to decide + * HOW a route's screen markup is obtained: + * + * - **Inline** (`template` field, string or `(params) => string` fn) — + * resolved SYNCHRONOUSLY at navigation time; the text lands on + * `next.locals.$template` and the navigation commits in the same + * digest turn (the sync fast path — see `route.ts`). + * - **Remote** (`templateUrl` field, string or `(params) => string` fn) — + * the URL is computed synchronously, then fetched via + * `$templateRequest`; the navigation commits only when the text + * arrives (async path). + * + * PRECEDENCE (upstream `getTemplateFor` parity): a PRESENT `template` + * field — even the function form — wins over `templateUrl`; the URL is + * never fetched when an inline template is declared. + * + * INLINE-FN FAILURE CONTRACT (deliberate, test-pinned): a `template` + * function that THROWS or returns a non-string is treated as "no inline + * text" here — {@link resolveInlineTemplate} returns `undefined`, the + * route still commits (without `locals.$template`), and `ngView`'s + * render-time fallback re-invokes the function, whose throw is caught + * there and routed via `$exceptionHandler('$compile')` (the pinned + * Slice-4 error contract). Upstream instead fails the whole navigation + * (`$routeChangeError`); this project keeps the Slice-4 behavior so the + * error surfaces through the established `'$compile'` channel. + * + * A `templateUrl` FUNCTION has no render-time fallback, so its throw is + * NOT swallowed — {@link resolveTemplateUrl} lets it propagate and the + * pipeline turns it into a `$routeChangeError` broadcast (upstream-style; + * the error event IS the channel, no `$exceptionHandler` routing). + */ + +import type { Route } from './route-types'; + +/** Whether the route declares an INLINE template (string or fn form). */ +export function hasInlineTemplate(route: Route): boolean { + return typeof route.template === 'string' || typeof route.template === 'function'; +} + +/** Whether the route declares a REMOTE template URL (string or fn form). */ +export function hasTemplateUrl(route: Route): boolean { + return typeof route.templateUrl === 'string' || typeof route.templateUrl === 'function'; +} + +/** + * Resolve the route's INLINE template to its text, or `undefined` when + * no usable inline text exists (absent field, fn returned a non-string, + * fn threw — see the file header for why a throw is swallowed here). + * + * The function form is called with `route.params` (upstream + * `getTemplateFor` parity). + */ +export function resolveInlineTemplate(route: Route): string | undefined { + const { template } = route; + if (typeof template === 'string') { + return template; + } + if (typeof template === 'function') { + try { + // Widen to `unknown` before the string check — the declared return + // type is `string`, but the fn is consumer-authored (the + // `RouteDefinition` index signature admits arbitrary shapes). + const produced: unknown = template(route.params); + return typeof produced === 'string' ? produced : undefined; + } catch { + // Deferred to ngView's render-time fallback, which re-invokes the + // fn and routes the throw via '$compile' (file-header contract). + return undefined; + } + } + return undefined; +} + +/** + * Resolve the route's `templateUrl` to the URL string to fetch, or + * `undefined` when the fn form returns a non-string (defensive — the + * route then commits with no template, matching the inline non-string + * degrade). The fn form is called with `route.params`. A THROW from the + * fn propagates to the caller, which broadcasts `$routeChangeError`. + */ +export function resolveTemplateUrl(route: Route): string | undefined { + const { templateUrl } = route; + if (typeof templateUrl === 'string') { + return templateUrl; + } + if (typeof templateUrl === 'function') { + const produced: unknown = templateUrl(route.params); + return typeof produced === 'string' ? produced : undefined; + } + return undefined; +} diff --git a/src/route/route-types.ts b/src/route/route-types.ts index 5ef004e..07e192b 100644 --- a/src/route/route-types.ts +++ b/src/route/route-types.ts @@ -100,9 +100,21 @@ export interface CompiledRouteEntry extends RouteDefinition { /** * A `$route.current` value — a shallow copy of the matched compiled entry - * plus the captured URL values (FS R11 / R12). Later slices add `locals`. + * plus the captured URL values (FS R11 / R12) and the resolved `locals`. */ export interface Route extends CompiledRouteEntry { + /** + * Resolved per-navigation artifacts — the upstream `nextRoute.locals` + * slot. Populated by the `$route` pipeline BEFORE the route commits + * (before `$routeChangeSuccess`): `$template` carries the resolved + * template TEXT — the inline `template` string, the inline + * `template(params)` function's return, or the `$templateRequest`-fetched + * body of `templateUrl` (FS R6). Slice 6 merges the resolved `resolve` + * entries into this SAME object, so `ngView` / controller wiring reads + * one uniform surface. Absent only on a route that never committed + * (pre-Slice-5 shapes, hand-built test doubles). + */ + locals?: { $template?: string } & Record; /** * Combined query + path values — upstream * `extend({}, $location.search(), pathParams)`: PATH params WIN over a diff --git a/src/route/route.ts b/src/route/route.ts index 01085d5..8ea9428 100644 --- a/src/route/route.ts +++ b/src/route/route.ts @@ -1,11 +1,11 @@ /** - * `createRoute` — the pure `$route` factory (spec 040 Slice 3). + * `createRoute` — the pure `$route` factory (spec 040 Slice 3; Slice 5 + * added the remote-template stage). * * Listens on `$rootScope.$on('$locationChangeSuccess')` — including the * INITIAL first-digest fire (`newUrl === oldUrl`) the `$location` watch * broadcasts, which is what resolves the initial route without any URL - * change. Each fire runs the (synchronous this slice — designed to grow the - * async template/resolve stages in a later slice) update pipeline: + * change. Each fire runs the update pipeline: * * 1. Match `$location.path()` against the compiled table in REGISTRATION * ORDER — first match wins (upstream iterates the routes hash in @@ -18,10 +18,43 @@ * 3. Broadcast `$routeChangeStart(next, previous)` — CANCELABLE * (`preventDefault()` aborts the navigation, upstream parity; the * current route and `$routeParams` stay untouched). - * 4. Set `$route.current = next`, repopulate the injected `$routeParams` - * IN PLACE (clear own keys, copy `next.params`) so references injected - * elsewhere stay live (upstream `$RouteParamsProvider` pattern). - * 5. Broadcast `$routeChangeSuccess(current, previous)`. + * 4. Resolve the route's template onto `next.locals.$template` (FS R6): + * - **Sync fast path (deliberate divergence, documented):** a route + * needing NO async work — an INLINE `template` (string or fn form) + * or no template at all — COMMITS synchronously in the same digest + * turn, exactly as Slices 3/4 did. Upstream funnels even inline + * templates through `$q.when(...)`, deferring every commit by a + * tick; this project keeps the established synchronous contracts + * (route.test.ts / ng-view.test.ts pin them). + * - **Async path:** `templateUrl` (string, or fn called with + * `next.params`) → `$q.when($templateRequest(url))` → on resolve, + * commit (the `$q` adoption schedules the continuation via + * `$evalAsync`, so the commit runs INSIDE a digest turn and bound + * content refreshes on its own); on reject → broadcast + * `$routeChangeError(next, previous, rejection)` and do NOT commit + * — `$route.current` stays `previous`, and since `ngView` reacts + * only to `$routeChangeSuccess` the previous screen stays intact. + * A fetch failure is NOT routed via `$exceptionHandler` — the + * error event IS the channel (upstream parity). + * - **Staleness guard:** each pass that survives the Start veto bumps + * the closure-local navigation token (the `ngInclude` + * `currentLoadToken` precedent); an async commit/error callback + * whose captured token is no longer the latest is silently dropped + * (no events, no state writes) — a fetch that loses the race to a + * newer navigation can never clobber it. + * 5. Commit: set `$route.current = next`, repopulate the injected + * `$routeParams` IN PLACE (clear own keys, copy `next.params`) so + * references injected elsewhere stay live (upstream + * `$RouteParamsProvider` pattern). + * 6. Broadcast `$routeChangeSuccess(current, previous)`. + * + * LOCALS CONTRACT (pinned): on a COMMITTED route, `next.locals` is ALWAYS + * an object; `locals.$template` is present ONLY when a template resolved + * to text (inline string, inline fn's string return, or the fetched + * `templateUrl` body). A route with no template — or an inline fn that + * threw / returned a non-string (see `route-template.ts` for that + * contract) — commits with `locals = {}`. Slice 6 merges resolved + * `resolve` entries into this SAME object. * * NO-MATCH SHAPE (pinned to upstream `updateRoute`): when neither a `next` * nor a `previous` route exists (initial load, nothing matches, no @@ -37,9 +70,12 @@ * slice adds; this slice rebuilds on every pass regardless. */ +import type { QService } from '@async/index'; import type { Scope } from '@core/index'; import type { LocationService } from '@location/index'; +import type { TemplateRequestFn } from '@template/index'; import { matchRoute } from './route-path'; +import { hasInlineTemplate, hasTemplateUrl, resolveInlineTemplate, resolveTemplateUrl } from './route-template'; import type { CompiledRouteEntry, Route, RouteParams, RouteService } from './route-types'; /** @@ -59,14 +95,29 @@ export interface CreateRouteArgs { routeParams: RouteParams; /** The compiled route table built by `$routeProvider.when()` / `.otherwise()`. */ routes: Record; + /** `$q` — adopts the `$templateRequest` promise so the async commit lands inside a digest turn. */ + q: QService; + /** `$templateRequest` — the cache-first template fetcher (`templateUrl` routes; FS R6 cache reuse for free). */ + templateRequest: TemplateRequestFn; } /** Build the `$route` service (see the file header for the pipeline). */ export function createRoute(args: CreateRouteArgs): RouteService { - const { rootScope, location, routeParams, routes } = args; + const { rootScope, location, routeParams, routes, q, templateRequest } = args; let forceReload = false; + /** + * The staleness sentinel (file header step 4): a fresh identity object + * per navigation pass that survives the Start veto. Async template + * callbacks capture the value at fetch-start and compare on settlement + * — a mismatch means a NEWER navigation ran (sync or async) and this + * pass's outcome must be dropped silently. A VETOED pass deliberately + * does NOT bump the token: a `preventDefault()`ed Start means "nothing + * happened", so an in-flight fetch from the previous pass stays valid. + */ + let latestNavigationToken: object = {}; + const $route: RouteService = { routes, current: undefined, @@ -103,7 +154,29 @@ export function createRoute(args: CreateRouteArgs): RouteService { return fallback === undefined ? undefined : { ...fallback, params: {}, pathParams: {}, $$route: fallback }; } - /** The navigation pipeline — steps 1–5 of the file header. */ + /** + * Commit a navigation (file header steps 5–6): set `$route.current`, + * repopulate `$routeParams` in place, broadcast `$routeChangeSuccess`. + * Runs synchronously on the fast path and from the `$q` continuation + * (inside a digest turn) on the `templateUrl` path. + */ + function commit(next: Route | undefined, previous: Route | undefined): void { + $route.current = next; + + // Repopulate $routeParams IN PLACE so injected references stay live + // (upstream $RouteParamsProvider pattern): clear own keys, copy params. + for (const key of Object.keys(routeParams)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- in-place repopulation is the $routeParams contract (injected references must observe the new values); keys are own enumerable route-param names, not arbitrary input + delete routeParams[key]; + } + if (next !== undefined) { + Object.assign(routeParams, next.params); + } + + rootScope.$broadcast('$routeChangeSuccess', next, previous); + } + + /** The navigation pipeline — steps 1–6 of the file header. */ function update(): void { const next = parseRoute(); const previous = $route.current; @@ -127,19 +200,65 @@ export function createRoute(args: CreateRouteArgs): RouteService { return; // navigation vetoed — current route and $routeParams untouched } - $route.current = next; + // Past the veto: this pass is now the authoritative navigation — any + // still-in-flight template fetch from an earlier pass goes stale. + const navigationToken = {}; + latestNavigationToken = navigationToken; - // Repopulate $routeParams IN PLACE so injected references stay live - // (upstream $RouteParamsProvider pattern): clear own keys, copy params. - for (const key of Object.keys(routeParams)) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- in-place repopulation is the $routeParams contract (injected references must observe the new values); keys are own enumerable route-param names, not arbitrary input - delete routeParams[key]; + // ── Template stage (file header step 4) ──────────────────────────── + // Inline `template` WINS over `templateUrl` (upstream `getTemplateFor` + // precedence), so the async path is reached only for genuinely + // remote-only routes. + if (next !== undefined && !hasInlineTemplate(next) && hasTemplateUrl(next)) { + let url: string | undefined; + try { + url = resolveTemplateUrl(next); + } catch (err: unknown) { + // A throwing `templateUrl` FUNCTION fails the navigation — the + // error event is the channel, never `$exceptionHandler` (see + // `route-template.ts`); no commit, the previous view stays. + rootScope.$broadcast('$routeChangeError', next, previous, err); + return; + } + if (url !== undefined) { + // ASYNC PATH. `$q.when` adopts the native `$templateRequest` + // promise, so both callbacks run inside a digest turn (`$q`'s + // `$evalAsync` scheduling) — the commit's Success broadcast and + // the render it triggers digest without any manual `$apply`. + q.when(templateRequest(url)).then( + (text) => { + if (latestNavigationToken !== navigationToken) { + return; // stale — a newer navigation won; drop silently + } + next.locals = typeof text === 'string' ? { $template: text } : {}; + commit(next, previous); + }, + (rejection: unknown) => { + if (latestNavigationToken !== navigationToken) { + return; // stale — the failed fetch belongs to a lost race + } + // Fetch failure: broadcast the error event and do NOT commit + // — `current` stays `previous`; `ngView` only reacts to + // Success, so the previous screen stays intact (FS R6). + rootScope.$broadcast('$routeChangeError', next, previous, rejection); + }, + ); + return; + } + // `templateUrl` fn returned a non-string — degrade to a + // template-less commit (mirrors the inline non-string degrade). + next.locals = {}; + commit(next, previous); + return; } + + // SYNC FAST PATH — inline template or no template at all: resolve the + // text (if any) and commit in the same digest turn (Slice-3/4 parity). if (next !== undefined) { - Object.assign(routeParams, next.params); + const text = resolveInlineTemplate(next); + next.locals = text === undefined ? {} : { $template: text }; } - - rootScope.$broadcast('$routeChangeSuccess', next, previous); + commit(next, previous); } rootScope.$on('$locationChangeSuccess', () => { From 6f54745ed6048199ab9affb8dd02c8b68fbcb6ea Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Tue, 7 Jul 2026 09:30:45 -0400 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20route=20resolve=20=E2=80=94=20pre?= =?UTF-8?q?-navigation=20data=20via=20$injector=20+=20object-form=20$q.all?= =?UTF-8?q?,=20$routeChangeError=20(spec=20040=20slice=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- context/spec/040-routing/tasks.md | 4 +- src/route/__tests__/route-resolve.test.ts | 594 ++++++++++++++++++++++ src/route/ng-view.ts | 25 +- src/route/route-provider.ts | 16 +- src/route/route.ts | 214 ++++++-- 5 files changed, 783 insertions(+), 70 deletions(-) create mode 100644 src/route/__tests__/route-resolve.test.ts diff --git a/context/spec/040-routing/tasks.md b/context/spec/040-routing/tasks.md index 10a05b6..f32c7b1 100644 --- a/context/spec/040-routing/tasks.md +++ b/context/spec/040-routing/tasks.md @@ -42,8 +42,8 @@ Each slice keeps the library in a runnable, green state (`pnpm typecheck && pnpm ## Slice 6: `resolve` + error handling -- [ ] Per-navigation `resolve` via `$injector.invoke(fn, null, locals)` + `$q.all`; expose `$route.current.locals` to the route controller; `$routeChangeError` broadcast + `$q` rejection on failure (no view swap). **[Agent: typescript-framework]** -- [ ] Verify: unit — resolve success populates locals, failure → `$routeChangeError` + view intact, sync/async ordering, `$q`-in-digest flush. **[Agent: vitest-testing]** +- [x] Per-navigation `resolve` via `$injector.invoke(fn, null, locals)` + `$q.all`; expose `$route.current.locals` to the route controller; `$routeChangeError` broadcast + `$q` rejection on failure (no view swap). **[Agent: typescript-framework]** +- [x] Verify: unit — resolve success populates locals, failure → `$routeChangeError` + view intact, sync/async ordering, `$q`-in-digest flush. **[Agent: vitest-testing]** ## Slice 7: Redirects, reload semantics, params update diff --git a/src/route/__tests__/route-resolve.test.ts b/src/route/__tests__/route-resolve.test.ts new file mode 100644 index 0000000..a4486b3 --- /dev/null +++ b/src/route/__tests__/route-resolve.test.ts @@ -0,0 +1,594 @@ +/** + * `$route` resolve-map tests via DI (spec 040 Slice 6; FS R9 pre-loading / + * R10 failure signal). + * + * Drive pattern (extends the Slice-5 `route-template-url.test.ts` contract): + * 1. `createInjector([ngModule, ngRoute, app])` — the app module's config + * block receives `$routeProvider`; the app module ALWAYS re-registers + * `$exceptionHandler` (last-wins `.factory`) as a RECORDER spy, so "zero + * unhandled-`$q` reports" is a positive assertion, not a console guess. + * When a mock fetch seam is needed the app module re-registers + * `$templateRequest` as the REAL `createTemplateRequest({ cache, fetcher })` + * closure (the Slice-5 precedent). + * 2. Compile + link a root carrying `` so commits render end-to-end. + * 3. Flush recipes (verified per resolve-entry kind): + * - plain values / already-resolved `$q` promises / string service names: + * `$location.path(p); $rootScope.$digest()` — ONE digest commits AND + * renders (the `$q.all` continuation drains via `$evalAsync` inside + * that same digest turn). + * - a deferred `$q` entry resolved later: navigate + digest (pending + * observable), then `deferred.resolve(v); $rootScope.$digest()`. + * - a NATIVE-Promise entry: navigate + digest, microtask awaits (the + * adoption `.then` must run), closing digest — `flushAsync`. + * - a rejection: navigate + digest broadcasts Error; run a SECOND digest + * before asserting zero recorder calls (gives `$q`'s deferred + * unhandled-rejection check a turn to prove the rejection was HANDLED). + * + * Pinned Slice-6 contracts: + * - EVERY resolve entry (string → `$injector.get(name)`; invokable → + * `$injector.invoke(value, null)`, NO locals) + the template text funnel + * through ONE object-keyed `$q.all` — the resolved object BECOMES + * `next.locals`, so resolve results are injectable into the route + * controller BY NAME (`ngView` spreads `{ ...locals, $scope: newScope }` + * — `$scope` wins). Upstream's `scope.$resolve` publication is NOT + * implemented — not tested for. + * - `$template` is assigned LAST — the REAL template wins over a same-named + * resolve entry (upstream assignment order). + * - ANY failure — rejecting entry, sync invoke throw (unknown service / + * throwing factory, caught per-entry and funneled as `q.reject`), fetch + * failure — converges on `$routeChangeError(next, previous, rejection)`: + * NO commit, `current` stays previous, the previous view stays mounted, + * and the rejection is HANDLED (zero `$exceptionHandler` reports). + * - STALENESS: the navigation token covers the resolve arm — a slow resolve + * settling (success OR failure) after a newer navigation is dropped + * silently (no events, no state writes, no DOM). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { QDeferred, QService } from '@async/index'; +import type { Scope, ScopeEvent } from '@core/index'; +import { ngModule } from '@core/ng-module'; +import { createInjector } from '@di/injector'; +import { createModule, resetRegistry } from '@di/module'; +import type { LocationService } from '@location/index'; +import { $RouteProvider, ngRoute, type Route, type RouteService } from '@route/index'; +import { createTemplateRequest } from '@template/template-request'; +import type { TemplateCacheService, TemplateFetcher, TemplateRequestFn } from '@template/template-types'; + +interface Harness { + root: HTMLElement; + $route: RouteService; + $rootScope: Scope; + $location: LocationService; + /** The recorder `$exceptionHandler` — MUST stay silent on every resolve path. */ + handler: ReturnType; +} + +interface BootOptions { + /** Mock network seam — wires the REAL `createTemplateRequest` over this fetcher (the Slice-5 pattern). */ + fetcher?: TemplateFetcher; + /** Plain services registered by NAME via `.value` — the string-form resolve entries look these up. */ + services?: Record; +} + +/** + * Register an `'app'` module whose config block receives `$routeProvider`, + * install the RECORDER `$exceptionHandler` (last-wins `.factory` — the + * `provide.test.ts` precedent), optionally register plain services and the + * mock fetch seam, build the injector, and compile + link a root carrying + * ``. Deps stay `[]` — the module OBJECTS are passed directly so a + * neighbouring `resetRegistry()` cannot evict them (the `route.test.ts` + * precedent). + */ +function boot(configure: (routeProvider: $RouteProvider) => void, options: BootOptions = {}): Harness { + const handler = vi.fn(); + const app = createModule('app', []).config(['$routeProvider', configure]); + app.factory('$exceptionHandler', [() => handler]); + for (const [name, value] of Object.entries(options.services ?? {})) { + app.value(name, value); + } + const fetcher = options.fetcher; + if (fetcher !== undefined) { + app.factory('$templateRequest', [ + '$templateCache', + (cache: TemplateCacheService): TemplateRequestFn => createTemplateRequest({ cache, fetcher }), + ]); + } + const injector = createInjector([ngModule, ngRoute, app]); + window.location.hash = ''; + const $route = injector.get('$route'); + const $rootScope = injector.get('$rootScope'); + const $location = injector.get('$location'); + const root = document.createElement('div'); + root.innerHTML = ''; + injector.get('$compile')(root)($rootScope); + return { root, $route, $rootScope, $location, handler }; +} + +/** + * Start a navigation: sync `$location` and run the pipeline up to (and + * including, for same-digest-settling resolve maps) the `$q.all` drain. + */ +function navigate(harness: Harness, path: string): void { + harness.$location.path(path); + harness.$rootScope.$digest(); +} + +/** + * The verified async flush recipe for NATIVE-promise legs (a native resolve + * entry or the mocked fetcher promise): microtask awaits let the native + * `.then` chain run and the `$q` adoption queue its `$evalAsync`; the + * closing digest drains the commit. Three awaits are defensive (the + * `route-template-url.test.ts` / `ng-include.test.ts` 3x-flush precedent). + */ +async function flushAsync(harness: Harness): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + harness.$rootScope.$digest(); +} + +interface RecordedRouteEvent { + name: string; + next: Route | undefined; + previous: Route | undefined; + rejection: unknown; +} + +/** Record every Start / Success / Error broadcast in fire order. */ +function recordRouteEvents($rootScope: Scope): RecordedRouteEvent[] { + const events: RecordedRouteEvent[] = []; + const listener = (event: ScopeEvent, ...args: unknown[]): void => { + events.push({ + name: event.name, + next: args[0] as Route | undefined, + previous: args[1] as Route | undefined, + rejection: args[2], + }); + }; + $rootScope.$on('$routeChangeStart', listener); + $rootScope.$on('$routeChangeSuccess', listener); + $rootScope.$on('$routeChangeError', listener); + return events; +} + +afterEach(() => { + resetRegistry(); + // Keep the shared jsdom URL clean for neighbouring tests — the digest + // flushes `$location` mutations into the real `window.location.hash`. + window.location.hash = ''; +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R9 — resolve success: all three entry kinds land in the controller BY NAME +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — resolve success (R9)', () => { + it('$q-backed + plain-value + string-service entries all reach the controller by name; locals carries them + $template; Success fires once', () => { + const seen: { user: string; config: { theme: string }; svc: string }[] = []; + const config = { theme: 'dark' }; + const harness = boot( + (p) => { + p.when('/profile', { + template: '

{{user}}

', + resolve: { + user: ['$q', ($q: QService) => $q.when('Alice')], + config: [() => config], + svc: 'greeter', + }, + controller: [ + 'user', + 'config', + 'svc', + '$scope', + (user: string, cfg: { theme: string }, svc: string, $scope: Scope) => { + seen.push({ user, config: cfg, svc }); + $scope.user = user; + }, + ], + }); + }, + { services: { greeter: 'hello-service' } }, + ); + const events = recordRouteEvents(harness.$rootScope); + + // The verified recipe: ONE digest commits AND renders — the `$q.all` + // continuation drains via `$evalAsync` inside the same digest turn. + navigate(harness, '/profile'); + + expect(seen).toEqual([{ user: 'Alice', config: { theme: 'dark' }, svc: 'hello-service' }]); + expect(seen[0]?.config).toBe(config); // the invokable's return, by reference + // current.locals carries every resolve result + the template text. + const locals = harness.$route.current?.locals; + expect(locals?.user).toBe('Alice'); + expect(locals?.config).toBe(config); + expect(locals?.svc).toBe('hello-service'); + expect(locals?.$template).toBe('

{{user}}

'); + // Rendered end-to-end — the controller's $scope write shows on first paint. + expect(harness.root.querySelector('.who')?.textContent).toBe('Alice'); + // Success fired exactly once; nothing routed via $exceptionHandler. + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(events[1]?.next).toBe(harness.$route.current); + harness.$rootScope.$digest(); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('all entries aggregate CONCURRENTLY through one $q.all — a plain + $q + NATIVE-promise mix all lands', async () => { + const harness = boot((p) => { + p.when('/mix', { + template: '

Mixed

', + resolve: { + plain: [() => 'p-value'], + viaQ: ['$q', ($q: QService) => $q.when('q-value')], + native: [() => Promise.resolve('n-value')], + }, + }); + }); + const events = recordRouteEvents(harness.$rootScope); + + navigate(harness, '/mix'); + // The native entry gates the aggregate — nothing committed yet. + expect(harness.$route.current).toBeUndefined(); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart']); + + await flushAsync(harness); + const locals = harness.$route.current?.locals; + expect(locals?.plain).toBe('p-value'); + expect(locals?.viaQ).toBe('q-value'); + expect(locals?.native).toBe('n-value'); + expect(locals?.$template).toBe('

Mixed

'); + expect(harness.root.querySelector('.mix')?.textContent).toBe('Mixed'); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R9 — pending: the previous screen stays put until the data is ready +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — resolve pending (R9)', () => { + it('while a deferred $q entry is pending the previous screen STAYS; deferred.resolve + digest commits and renders', () => { + let deferred: QDeferred | undefined; + const harness = boot((p) => { + p.when('/start', { template: '

Start

' }).when('/pending', { + template: '

{{user}}

', + resolve: { + user: [ + '$q', + ($q: QService) => { + deferred = $q.defer(); + return deferred.promise; + }, + ], + }, + controller: [ + 'user', + '$scope', + (user: string, $scope: Scope) => { + $scope.user = user; + }, + ], + }); + }); + navigate(harness, '/start'); + const previous = harness.$route.current; + expect(harness.root.querySelector('.start')).not.toBeNull(); + + const events = recordRouteEvents(harness.$rootScope); + navigate(harness, '/pending'); + // Start fired; the resolve is IN FLIGHT — no commit, no Success, the + // previous screen still mounted, current unchanged (R9 acceptance). + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart']); + expect(harness.$route.current).toBe(previous); + expect(harness.root.querySelector('.start')).not.toBeNull(); + expect(harness.root.querySelector('.user')).toBeNull(); + + if (deferred === undefined) { + throw new Error('the resolve invokable never ran'); + } + deferred.resolve('Bea'); + harness.$rootScope.$digest(); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(harness.$route.current?.locals?.user).toBe('Bea'); + expect(harness.root.querySelector('.start')).toBeNull(); + expect(harness.root.querySelector('.user')?.textContent).toBe('Bea'); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R9 failure — $routeChangeError, no commit, previous view intact, HANDLED +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — resolve failure (R9 / R10 error contract)', () => { + it('a rejecting $q entry broadcasts $routeChangeError with the EXACT rejection; no commit; previous view intact; zero handler reports; recovery works', () => { + const boom = new Error('resolve boom'); + const harness = boot((p) => { + p.when('/start', { template: '

Start

' }) + .when('/fail', { + template: '

never shown

', + resolve: { data: ['$q', ($q: QService) => $q.reject(boom)] }, + }) + .when('/good', { template: '

Recovered

' }); + }); + navigate(harness, '/start'); + const previous = harness.$route.current; + + const events = recordRouteEvents(harness.$rootScope); + navigate(harness, '/fail'); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeError']); + const errorEvent = events[1]; + expect(errorEvent?.rejection).toBe(boom); + expect(errorEvent?.next?.$$route).toBe(harness.$route.routes['/fail']); + expect(errorEvent?.previous).toBe(previous); + // NO commit: current stays previous, the previous screen stays mounted. + expect(harness.$route.current).toBe(previous); + expect(harness.root.querySelector('.start')).not.toBeNull(); + expect(harness.root.querySelector('.fail')).toBeNull(); + + // The rejection is HANDLED by the aggregate's arms — a second digest + // gives $q's deferred unhandled-rejection check its turn: still silent. + harness.$rootScope.$digest(); + expect(harness.handler).not.toHaveBeenCalled(); + + // A subsequent good navigation works normally. + navigate(harness, '/good'); + expect(harness.$route.current?.locals?.$template).toBe('

Recovered

'); + expect(harness.root.querySelector('.good')?.textContent).toBe('Recovered'); + expect(events.map((e) => e.name)).toEqual([ + '$routeChangeStart', + '$routeChangeError', + '$routeChangeStart', + '$routeChangeSuccess', + ]); + }); + + it('a SYNCHRONOUSLY-throwing invokable is funneled into the same Error path (not a digest crash), rejection === the thrown error', () => { + const boom = new Error('factory boom'); + const harness = boot((p) => { + p.when('/start', { template: '

S

' }).when('/throws', { + template: '

never

', + resolve: { + data: [ + () => { + throw boom; + }, + ], + }, + }); + }); + navigate(harness, '/start'); + const previous = harness.$route.current; + + const events = recordRouteEvents(harness.$rootScope); + expect(() => { + navigate(harness, '/throws'); + }).not.toThrow(); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeError']); + expect(events[1]?.rejection).toBe(boom); + expect(harness.$route.current).toBe(previous); + expect(harness.root.querySelector('.start')).not.toBeNull(); + harness.$rootScope.$digest(); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it("a string entry naming an UNKNOWN service funnels the injector's sync throw into the Error path", () => { + const harness = boot((p) => { + p.when('/start', { template: '

S

' }).when('/unknown', { + template: '

never

', + resolve: { svc: '$missingService' }, + }); + }); + navigate(harness, '/start'); + const previous = harness.$route.current; + + const events = recordRouteEvents(harness.$rootScope); + expect(() => { + navigate(harness, '/unknown'); + }).not.toThrow(); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeError']); + expect(events[1]?.rejection).toBeInstanceOf(Error); + expect(harness.$route.current).toBe(previous); + expect(harness.root.querySelector('.start')).not.toBeNull(); + harness.$rootScope.$digest(); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// templateUrl + resolve compose — BOTH must complete before the commit +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — templateUrl + resolve compose', () => { + it('releasing the template alone keeps the route pending; releasing the resolve too commits with BOTH in locals', async () => { + let releaseTemplate: ((text: string) => void) | undefined; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + releaseTemplate = resolve; + }), + ); + let deferred: QDeferred | undefined; + const harness = boot( + (p) => { + p.when('/both', { + templateUrl: '/tpl/both.html', + resolve: { + user: [ + '$q', + ($q: QService) => { + deferred = $q.defer(); + return deferred.promise; + }, + ], + }, + }); + }, + { fetcher }, + ); + const events = recordRouteEvents(harness.$rootScope); + + navigate(harness, '/both'); + expect(fetcher).toHaveBeenCalledWith('/tpl/both.html'); + if (releaseTemplate === undefined || deferred === undefined) { + throw new Error('the fetch / resolve legs never started'); + } + + // Release the TEMPLATE only — the resolve entry still gates the commit. + releaseTemplate('

Fetched

'); + await flushAsync(harness); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart']); + expect(harness.$route.current).toBeUndefined(); + expect(harness.root.querySelector('.both')).toBeNull(); + + // Release the RESOLVE — now the aggregate settles and the route commits. + deferred.resolve('Cleo'); + harness.$rootScope.$digest(); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(harness.$route.current?.locals?.$template).toBe('

Fetched

'); + expect(harness.$route.current?.locals?.user).toBe('Cleo'); + expect(harness.root.querySelector('.both')?.textContent).toBe('Fetched'); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// $template collision — the REAL template wins (upstream assignment order) +// ──────────────────────────────────────────────────────────────────────────── + +describe("$route — a resolve entry named '$template'", () => { + it('is overwritten by the REAL template text (assigned LAST) — the resolve value never renders', () => { + const harness = boot((p) => { + p.when('/collide', { + template: '

Real template

', + resolve: { $template: [() => '

From resolve

'] }, + }); + }); + navigate(harness, '/collide'); + + expect(harness.$route.current?.locals?.$template).toBe('

Real template

'); + expect(harness.root.querySelector('.real')?.textContent).toBe('Real template'); + expect(harness.root.querySelector('.fake')).toBeNull(); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// staleness — a slow resolve losing to a newer navigation is dropped silently +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — stale-resolve drop', () => { + it('a slow resolve SUCCESS after a newer commit is dropped — no events, no state write, no DOM', async () => { + let deferred: QDeferred | undefined; + const harness = boot((p) => { + p.when('/slow', { + template: '

Slow

', + resolve: { + data: [ + '$q', + ($q: QService) => { + deferred = $q.defer(); + return deferred.promise; + }, + ], + }, + }).when('/fast', { template: '

Fast

' }); + }); + + navigate(harness, '/slow'); + expect(harness.$route.current).toBeUndefined(); // resolve in flight + + // A NEWER navigation commits synchronously and bumps the token — the + // in-flight slow resolve is now stale. + navigate(harness, '/fast'); + const committed = harness.$route.current; + expect(harness.root.querySelector('.fast')).not.toBeNull(); + + const events = recordRouteEvents(harness.$rootScope); + if (deferred === undefined) { + throw new Error('the slow resolve never started'); + } + deferred.resolve('too late'); + harness.$rootScope.$digest(); + await flushAsync(harness); // defensive extra flush — the drop must hold + + expect(events).toEqual([]); + expect(harness.$route.current).toBe(committed); + expect(harness.root.querySelector('.slow')).toBeNull(); + expect(harness.root.querySelector('.fast')?.textContent).toBe('Fast'); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('a slow resolve FAILURE after a newer commit is dropped — no Error event, zero handler reports', async () => { + let deferred: QDeferred | undefined; + const harness = boot((p) => { + p.when('/slow', { + template: '

Slow

', + resolve: { + data: [ + '$q', + ($q: QService) => { + deferred = $q.defer(); + return deferred.promise; + }, + ], + }, + }).when('/fast', { template: '

Fast

' }); + }); + + navigate(harness, '/slow'); + navigate(harness, '/fast'); + const committed = harness.$route.current; + + const events = recordRouteEvents(harness.$rootScope); + if (deferred === undefined) { + throw new Error('the slow resolve never started'); + } + deferred.reject(new Error('too late to fail')); + harness.$rootScope.$digest(); + await flushAsync(harness); + + expect(events).toEqual([]); + expect(harness.$route.current).toBe(committed); + expect(harness.root.querySelector('.fast')).not.toBeNull(); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// $scope wins over a same-named resolve entry (upstream ngViewFillContent) +// ──────────────────────────────────────────────────────────────────────────── + +describe("$route — controller '$scope' local wins over a resolve entry named '$scope'", () => { + it("injects the LIVE child scope into the controller; the resolve value stays only on current.locals['$scope']", () => { + let captured: unknown; + const harness = boot((p) => { + p.when('/scoped', { + template: '

{{msg}}

', + resolve: { $scope: [() => 'not-a-scope'] }, + controller: [ + '$scope', + ($scope: Scope) => { + captured = $scope; + $scope.msg = 'live scope'; + }, + ], + }); + }); + navigate(harness, '/scoped'); + + // The controller received the real child scope, not the resolve string — + // its write rendering through the template is the live-scope proof. + expect(captured).not.toBe('not-a-scope'); + expect(harness.root.querySelector('.msg')?.textContent).toBe('live scope'); + // The resolve entry itself still landed on the route's locals. + expect(harness.$route.current?.locals?.$scope).toBe('not-a-scope'); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/route/ng-view.ts b/src/route/ng-view.ts index 389691b..559cc94 100644 --- a/src/route/ng-view.ts +++ b/src/route/ng-view.ts @@ -35,9 +35,18 @@ * throw contract (the fn's throw is caught here and routed via * `'$compile'` — see `route-template.ts`'s file header). A committed * route with NO template renders nothing, silently. - * TODO(slice-6): `resolve` locals — spread `$route.current.locals` into - * the controller locals and publish them on the child scope under - * `resolveAs ?? '$resolve'`. + * + * **Resolve locals (spec 040 Slice 6 — FS R9).** The route controller is + * instantiated with `{ ...current.locals, $scope: newScope }` — the + * upstream `ngViewFillContent` shape (upstream assigns + * `locals.$scope = scope` onto the route's locals object; this spread is + * the non-mutating equivalent, `$scope` winning over any same-named + * resolve entry). Every resolved `resolve` entry is thereby injectable + * into the controller BY NAME; the `$template` text riding along in + * `locals` is harmless (injected only if a controller asks for + * `$template`, upstream parity). Publishing the locals on the child + * scope under `resolveAs ?? '$resolve'` (upstream 1.6+) is NOT + * implemented — deferred with the other route-definition extras. * * **Render order (pinned, upstream `ngViewFillContent` parity):** * 1. Tear down the previous clone (`currentScope.$destroy()` BEFORE @@ -274,9 +283,13 @@ function ngViewFactory( const linker = $compile(container); if (current.controller !== undefined) { - // TODO(slice-6): spread `current.locals` (resolved `resolve` - // values) into the locals map ahead of `$scope`. - const locals: ControllerLocals = { $scope: newScope }; + // Resolve locals (Slice 6, FS R9): spread the route's resolved + // `locals` (resolve entries + `$template`) ahead of `$scope` + // so every pre-loaded value is injectable by name — `$scope` + // wins on collision (upstream `ngViewFillContent` assigns + // `locals.$scope = scope` onto the locals object; this is the + // non-mutating equivalent). + const locals: ControllerLocals = { ...current.locals, $scope: newScope }; // `RouteDefinition.controller` is typed with @di's `Invokable` // (author-friendly — typed-param controller fns are // assignable); `$controller` accepts the equivalent diff --git a/src/route/route-provider.ts b/src/route/route-provider.ts index 7caa515..fe171f6 100644 --- a/src/route/route-provider.ts +++ b/src/route/route-provider.ts @@ -16,12 +16,12 @@ * string shorthand `otherwise('/home')` normalizes to * `{ redirectTo: '/home' }` (upstream parity). * - `$get` is `['$rootScope', '$location', '$routeParams', '$q', - * '$templateRequest', factory]` — the collaborators the pipeline - * consumes: the Slice-3 digest trio plus the Slice-5 async-template - * pair (`$q` adopts the fetch promise into the digest, - * `$templateRequest` is the cache-first fetcher). `$injector` is - * deliberately NOT injected yet; the `resolve` slice widens the dep - * list when it adds dependency pre-loading. + * '$templateRequest', '$injector', factory]` — the collaborators the + * pipeline consumes: the Slice-3 digest trio, the Slice-5 + * async-template pair (`$q` adopts the fetch promise into the digest, + * `$templateRequest` is the cache-first fetcher), and the Slice-6 + * `$injector` (resolves `resolve`-map entries — `get` for string + * names, `invoke` for invokables). * * TRAILING-SLASH MECHANISM (documented divergence): upstream `when()` * registers a companion `{ redirectTo }` route per pattern (pattern ± @@ -35,6 +35,7 @@ import type { QService } from '@async/index'; import type { Scope } from '@core/index'; +import type { Injector } from '@di/di-types'; import type { LocationService } from '@location/index'; import type { TemplateRequestFn } from '@template/index'; import { pathRegExp } from './route-path'; @@ -102,12 +103,14 @@ export class $RouteProvider { '$routeParams', '$q', '$templateRequest', + '$injector', ( $rootScope: Scope, $location: LocationService, $routeParams: RouteParams, $q: QService, $templateRequest: TemplateRequestFn, + $injector: Injector, ): RouteService => createRoute({ rootScope: $rootScope, @@ -116,6 +119,7 @@ export class $RouteProvider { routes: this.$$routes, q: $q, templateRequest: $templateRequest, + injector: $injector, }), ] as const; } diff --git a/src/route/route.ts b/src/route/route.ts index 8ea9428..b63a1c8 100644 --- a/src/route/route.ts +++ b/src/route/route.ts @@ -18,30 +18,41 @@ * 3. Broadcast `$routeChangeStart(next, previous)` — CANCELABLE * (`preventDefault()` aborts the navigation, upstream parity; the * current route and `$routeParams` stay untouched). - * 4. Resolve the route's template onto `next.locals.$template` (FS R6): + * 4. Resolve the route's template AND its `resolve` map onto `next.locals` + * (FS R6 / R9): * - **Sync fast path (deliberate divergence, documented):** a route * needing NO async work — an INLINE `template` (string or fn form) - * or no template at all — COMMITS synchronously in the same digest - * turn, exactly as Slices 3/4 did. Upstream funnels even inline - * templates through `$q.when(...)`, deferring every commit by a - * tick; this project keeps the established synchronous contracts - * (route.test.ts / ng-view.test.ts pin them). - * - **Async path:** `templateUrl` (string, or fn called with - * `next.params`) → `$q.when($templateRequest(url))` → on resolve, - * commit (the `$q` adoption schedules the continuation via - * `$evalAsync`, so the commit runs INSIDE a digest turn and bound - * content refreshes on its own); on reject → broadcast - * `$routeChangeError(next, previous, rejection)` and do NOT commit - * — `$route.current` stays `previous`, and since `ngView` reacts - * only to `$routeChangeSuccess` the previous screen stays intact. - * A fetch failure is NOT routed via `$exceptionHandler` — the - * error event IS the channel (upstream parity). + * or no template at all, AND no `resolve` map — COMMITS synchronously + * in the same digest turn, exactly as Slices 3/4 did. Upstream + * funnels even inline templates through `$q.when(...)`, deferring + * every commit by a tick; this project keeps the established + * synchronous contracts (route.test.ts / ng-view.test.ts pin them). + * - **Async path (`templateUrl` and/or `resolve` — Slice 6):** every + * `resolve` entry (string → `$injector.get(name)`; invokable → + * `$injector.invoke(value, null)` with NO locals — upstream parity, + * resolve fns inject `$route` / `$routeParams` themselves) plus the + * template text (`$q.when($templateRequest(url))` for `templateUrl`; + * the inline string as a plain value) are aggregated through ONE + * object-keyed `$q.all({...entries, $template})` — the resolved + * object BECOMES `next.locals`, so pre-loaded data reaches the + * route controller by name. On resolve → commit (the `$q` + * continuation runs via `$evalAsync`, INSIDE a digest turn, so + * bound content refreshes on its own). ANY rejection — a rejecting + * resolve promise, a synchronously-throwing invokable (funneled via + * `q.reject`), or a fetch failure — broadcasts + * `$routeChangeError(next, previous, rejection)` and does NOT + * commit — `$route.current` stays `previous`, and since `ngView` + * reacts only to `$routeChangeSuccess` the previous screen stays + * intact. Failures are NOT routed via `$exceptionHandler` — the + * error event IS the channel, and the rejection is HANDLED by the + * aggregate's `.then` arms so no unhandled-`$q` report fires + * (upstream parity). * - **Staleness guard:** each pass that survives the Start veto bumps * the closure-local navigation token (the `ngInclude` * `currentLoadToken` precedent); an async commit/error callback * whose captured token is no longer the latest is silently dropped - * (no events, no state writes) — a fetch that loses the race to a - * newer navigation can never clobber it. + * (no events, no state writes) — a fetch/resolve that loses the + * race to a newer navigation can never clobber it. * 5. Commit: set `$route.current = next`, repopulate the injected * `$routeParams` IN PLACE (clear own keys, copy `next.params`) so * references injected elsewhere stay live (upstream @@ -53,8 +64,10 @@ * to text (inline string, inline fn's string return, or the fetched * `templateUrl` body). A route with no template — or an inline fn that * threw / returned a non-string (see `route-template.ts` for that - * contract) — commits with `locals = {}`. Slice 6 merges resolved - * `resolve` entries into this SAME object. + * contract) — commits with `locals = {}`. Slice 6 merges the resolved + * `resolve` entries into this SAME object (`$template` wins over a + * same-named resolve entry — upstream assignment order), so `ngView` + * spreads one uniform surface into the controller locals. * * NO-MATCH SHAPE (pinned to upstream `updateRoute`): when neither a `next` * nor a `previous` route exists (initial load, nothing matches, no @@ -70,8 +83,9 @@ * slice adds; this slice rebuilds on every pass regardless. */ -import type { QService } from '@async/index'; +import type { QPromise, QService } from '@async/index'; import type { Scope } from '@core/index'; +import type { Injector, Invokable } from '@di/di-types'; import type { LocationService } from '@location/index'; import type { TemplateRequestFn } from '@template/index'; import { matchRoute } from './route-path'; @@ -99,11 +113,29 @@ export interface CreateRouteArgs { q: QService; /** `$templateRequest` — the cache-first template fetcher (`templateUrl` routes; FS R6 cache reuse for free). */ templateRequest: TemplateRequestFn; + /** `$injector` — resolves the route's `resolve` map entries (`get` for string names, `invoke` for invokables — FS R9). */ + injector: Injector; +} + +/** + * Extract the route's `resolve` map when it is a usable object. Widened to + * `unknown` before checking (the `resolveTemplateString` precedent in + * `ng-view.ts`): the `RouteDefinition` index signature admits arbitrary + * consumer shapes, and `typeof null === 'object'` makes the explicit null + * guard load-bearing at runtime even though the DECLARED field type + * excludes `null`. The value-type assertion is safe because the sole use + * site re-discriminates every entry (`typeof value === 'string'` → get; + * anything else → invoke, whose throw on garbage funnels into the `$q` + * rejection channel). + */ +function getResolveMap(route: Route): Record | undefined { + const resolve: unknown = route.resolve; + return typeof resolve === 'object' && resolve !== null ? (resolve as Record) : undefined; } /** Build the `$route` service (see the file header for the pipeline). */ export function createRoute(args: CreateRouteArgs): RouteService { - const { rootScope, location, routeParams, routes, q, templateRequest } = args; + const { rootScope, location, routeParams, routes, q, templateRequest, injector } = args; let forceReload = false; @@ -205,55 +237,125 @@ export function createRoute(args: CreateRouteArgs): RouteService { const navigationToken = {}; latestNavigationToken = navigationToken; - // ── Template stage (file header step 4) ──────────────────────────── - // Inline `template` WINS over `templateUrl` (upstream `getTemplateFor` - // precedence), so the async path is reached only for genuinely - // remote-only routes. - if (next !== undefined && !hasInlineTemplate(next) && hasTemplateUrl(next)) { - let url: string | undefined; - try { - url = resolveTemplateUrl(next); - } catch (err: unknown) { - // A throwing `templateUrl` FUNCTION fails the navigation — the - // error event is the channel, never `$exceptionHandler` (see - // `route-template.ts`); no commit, the previous view stays. - rootScope.$broadcast('$routeChangeError', next, previous, err); - return; - } - if (url !== undefined) { - // ASYNC PATH. `$q.when` adopts the native `$templateRequest` - // promise, so both callbacks run inside a digest turn (`$q`'s - // `$evalAsync` scheduling) — the commit's Success broadcast and - // the render it triggers digest without any manual `$apply`. - q.when(templateRequest(url)).then( - (text) => { + // ── Template + resolve stage (file header step 4) ────────────────── + // A route with a `resolve` map and/or a remote `templateUrl` funnels + // EVERYTHING — resolve entries + template text — through ONE + // object-keyed `$q.all` (upstream `resolveLocals` parity); the + // resolved object BECOMES `next.locals`. Inline `template` WINS over + // `templateUrl` (upstream `getTemplateFor` precedence): an inline + // string rides through `$q.all` as a plain (already-resolved) value. + if (next !== undefined) { + const resolveMap = getResolveMap(next); + const remoteOnly = !hasInlineTemplate(next) && hasTemplateUrl(next); + + if (resolveMap !== undefined || remoteOnly) { + // 1. Template source FIRST — a throwing `templateUrl` FUNCTION + // fails the navigation SYNCHRONOUSLY, before any resolve + // invokable runs (the pinned Slice-5 contract; the error event + // is the channel, never `$exceptionHandler`). Ordering the + // sync bail ahead of the resolve loop also guarantees no + // orphaned `q.reject` entry is ever left unhandled. + // `$templateRequest` resolves `string | undefined` (an + // `ignoreRequestError` seam this pipeline never opts into) — the + // aggregate's success arm re-narrows with `typeof === 'string'` + // (the pinned Slice-5 non-string degrade). + let templateSource: string | QPromise | undefined; + if (hasInlineTemplate(next)) { + templateSource = resolveInlineTemplate(next); + } else if (remoteOnly) { + let url: string | undefined; + try { + url = resolveTemplateUrl(next); + } catch (err: unknown) { + rootScope.$broadcast('$routeChangeError', next, previous, err); + return; + } + if (url !== undefined) { + // `$q.when` adopts the native `$templateRequest` promise, so + // the aggregate settles inside a digest turn (`$q`'s + // `$evalAsync` scheduling) — no manual `$apply` anywhere. + templateSource = q.when(templateRequest(url)); + } + } + + if (templateSource === undefined && resolveMap === undefined) { + // `templateUrl` fn returned a non-string and there is nothing + // to resolve — degrade to a template-less SYNC commit (the + // Slice-5 behavior, mirroring the inline non-string degrade). + next.locals = {}; + commit(next, previous); + return; + } + + // 2. Resolve entries (FS R9; upstream `resolveLocals`): a string + // is a service NAME (`$injector.get`); anything else is an + // invokable (`$injector.invoke(value, null)` — NO locals, + // upstream parity: resolve fns inject `$route` / + // `$routeParams` themselves). A SYNC throw (unknown service, + // un-annotated fn, throwing factory) funnels into the same + // `$q` rejection channel via `q.reject`, so every resolve + // failure surfaces uniformly through the aggregate's + // rejection arm below. + const pending: Record = {}; + if (resolveMap !== undefined) { + for (const [key, value] of Object.entries(resolveMap)) { + try { + pending[key] = typeof value === 'string' ? injector.get(value) : injector.invoke(value, null); + } catch (err: unknown) { + pending[key] = q.reject(err); + } + } + } + + // 3. Template LAST — `$template` wins over a same-named resolve + // entry (upstream assigns `locals['$template']` after the + // entry loop). + if (templateSource !== undefined) { + pending.$template = templateSource; + } + + // 4. Aggregate. `$q.all` passes plain values through and rejects + // on the FIRST failure. BOTH arms are attached, so a resolve / + // fetch rejection is HANDLED — no unhandled-`$q` report fires + // (upstream's `.then(onSuccess, onError)` chain likewise + // leaves the rejection handled). + q.all(pending).then( + (resolved) => { if (latestNavigationToken !== navigationToken) { return; // stale — a newer navigation won; drop silently } - next.locals = typeof text === 'string' ? { $template: text } : {}; + const locals: { $template?: string } & Record = {}; + for (const [key, value] of Object.entries(resolved)) { + if (key !== '$template') { + locals[key] = value; + } + } + if (typeof resolved.$template === 'string') { + locals.$template = resolved.$template; + } + next.locals = locals; commit(next, previous); }, (rejection: unknown) => { if (latestNavigationToken !== navigationToken) { - return; // stale — the failed fetch belongs to a lost race + return; // stale — the failure belongs to a lost race } - // Fetch failure: broadcast the error event and do NOT commit - // — `current` stays `previous`; `ngView` only reacts to - // Success, so the previous screen stays intact (FS R6). + // Resolve / fetch failure: broadcast the error event and do + // NOT commit — `current` stays `previous`; `ngView` only + // reacts to Success, so the previous screen stays intact + // (FS R6 / R9). rootScope.$broadcast('$routeChangeError', next, previous, rejection); }, ); return; } - // `templateUrl` fn returned a non-string — degrade to a - // template-less commit (mirrors the inline non-string degrade). - next.locals = {}; - commit(next, previous); - return; } - // SYNC FAST PATH — inline template or no template at all: resolve the - // text (if any) and commit in the same digest turn (Slice-3/4 parity). + // SYNC FAST PATH — no `templateUrl` AND no `resolve` map: resolve the + // inline text (if any) and commit in the same digest turn (Slice-3/4 + // parity). A route with EITHER goes through the async `$q.all` path + // above — resolve entries may return promises, so even an + // inline-template route with a `resolve` map commits asynchronously. if (next !== undefined) { const text = resolveInlineTemplate(next); next.locals = text === undefined ? {} : { $template: text }; From e6bbd9b99cb200689b4bd3c08163f0fc0bd7da92 Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Tue, 7 Jul 2026 10:26:51 -0400 Subject: [PATCH 08/13] feat: route redirects, $routeUpdate reload semantics, updateParams, loop guard (spec 040 slice 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- context/spec/040-routing/tasks.md | 4 +- src/index.ts | 8 +- .../__tests__/route-redirect-reload.test.ts | 643 ++++++++++++++++++ src/route/__tests__/route.test.ts | 17 +- src/route/index.ts | 1 + src/route/route-error.ts | 52 ++ src/route/route-path.ts | 13 +- src/route/route-redirect.ts | 62 ++ src/route/route-types.ts | 52 +- src/route/route.ts | 224 +++++- 10 files changed, 1040 insertions(+), 36 deletions(-) create mode 100644 src/route/__tests__/route-redirect-reload.test.ts create mode 100644 src/route/route-error.ts create mode 100644 src/route/route-redirect.ts diff --git a/context/spec/040-routing/tasks.md b/context/spec/040-routing/tasks.md index f32c7b1..ef29883 100644 --- a/context/spec/040-routing/tasks.md +++ b/context/spec/040-routing/tasks.md @@ -47,8 +47,8 @@ Each slice keeps the library in a runnable, green state (`pnpm typecheck && pnpm ## Slice 7: Redirects, reload semantics, params update -- [ ] `redirectTo` (static + fn via `$location.replace().url(...)`), `RouteRedirectionLoopError` loop detection, `reloadOnSearch`/`reloadOnUrl` → `$routeUpdate` (no teardown), `$route.reload()`, `$route.updateParams()`. **[Agent: typescript-framework]** -- [ ] Verify: unit — static/computed redirect, loop detection, query-only → `$routeUpdate`, `reload()` re-runs resolve, `updateParams()`. **[Agent: vitest-testing]** +- [x] `redirectTo` (static + fn via `$location.replace().url(...)`), `RouteRedirectionLoopError` loop detection, `reloadOnSearch`/`reloadOnUrl` → `$routeUpdate` (no teardown), `$route.reload()`, `$route.updateParams()`. **[Agent: typescript-framework]** +- [x] Verify: unit — static/computed redirect, loop detection, query-only → `$routeUpdate`, `reload()` re-runs resolve, `updateParams()`. **[Agent: vitest-testing]** ## Slice 8: Parity suites, docs & wrap-up diff --git a/src/index.ts b/src/index.ts index b0e2684..0346f77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -264,7 +264,13 @@ export type { SearchValue, } from './location/index'; -export { createRoute, ngRoute, OTHERWISE_ROUTE_KEY } from './route/index'; +export { + createRoute, + ngRoute, + OTHERWISE_ROUTE_KEY, + REDIRECT_LOOP_THRESHOLD, + RouteRedirectionLoopError, +} from './route/index'; export type { CompiledRouteEntry, CreateRouteArgs, diff --git a/src/route/__tests__/route-redirect-reload.test.ts b/src/route/__tests__/route-redirect-reload.test.ts new file mode 100644 index 0000000..bad6e83 --- /dev/null +++ b/src/route/__tests__/route-redirect-reload.test.ts @@ -0,0 +1,643 @@ +/** + * `$route` redirect / reload-semantics / `updateParams` tests via DI + * (spec 040 Slice 7; FS R8 redirects, R13 manual reload, R14 query-change + * reload behavior, R10's `$routeUpdate` signal). + * + * Drive pattern (extends the Slice-6 `route-resolve.test.ts` contract): + * 1. `createInjector([ngModule, ngRoute, app])` — the app module's config + * block receives `$routeProvider`; the app module ALWAYS re-registers + * `$exceptionHandler` (last-wins `.factory`) as a RECORDER spy so "no + * handler noise" is a positive assertion. + * 2. Compile + link a root carrying `` so commits render end-to-end + * (view persistence across `$routeUpdate` is a DOM-node-identity check). + * 3. Flush recipes (verified against the Slice-7 implementation): + * - INITIAL load onto a redirecting route: ONE `$digest()` completes BOTH + * hops — the first location-watch fire is inherently dirty, so the + * redirected URL is processed on a later iteration of the SAME digest. + * - LATER navigation onto a redirecting route: TWO `$digest()` calls — + * digest 1 writes the redirect into `$location`, digest 2 commits the + * target. + * - redirect LOOP: navigate, then a bounded for-loop of digests + * (`REDIRECT_LOOP_THRESHOLD + 5`) — the loop error fires once and every + * following digest is silent. + * - `updateParams` / `reload()` / `$routeUpdate`: one digest after the + * trigger. + * + * Pinned Slice-7 contracts: + * - String `redirectTo`: `$location.path(interpolate(redirectTo, + * next.params)).search(next.params).replace()` — interpolation CONSUMES + * the path-used keys, so `.search(...)` carries only the leftover (query) + * values. + * - Function `redirectTo`: called `(next.pathParams, $location.path(), + * $location.search())`; the result goes through + * `$location.url(result).replace()`; an `undefined` return means NO + * redirect — the route processes normally. + * - Event divergence (deliberate, documented in route.ts): the redirect + * pass emits NO route events and never touches `current`; only the + * TARGET URL's pass fires Start/Success. + * - History parity NOTE: both redirect writes mark `$location.replace()` so + * the intermediate URL leaves no history entry. The replace flag is + * internal to `$location`'s digest flush and jsdom's hashchange plumbing + * maintains no faithful `history.length`, so there is no reliable runtime + * observable — the `.replace()` calls are pinned at the source level in + * `route.ts` and NOT asserted here. + * - Loop guard: past `REDIRECT_LOOP_THRESHOLD` CONSECUTIVE redirect passes + * → `$routeChangeError(next, previous, RouteRedirectionLoopError)`, + * `$location` NOT written again, then silence until a real navigation. + * - `$routeUpdate` (R14): same `$$route` + (`reloadOnUrl: false` OR + * (`reloadOnSearch: false` AND path params unchanged)) → `current.params` + * and `$routeParams` updated IN PLACE, `$routeUpdate(current)` broadcast, + * NO Start/Success, view persists (`ngView` reacts only to Success). + * `current.pathParams` is left STALE on a `reloadOnUrl: false` path + * change (upstream copies only `params` — documented parity). + * - `reload()` (R13): `forceReload` bypasses the update-only branch — full + * rebuild including a fresh `resolve` run and a fresh controller. + * - `updateParams(newParams)`: merges over `current.params`, writes + * `$location.path(...)` + leftover `.search(...)` with NO `.replace()` + * (a normal history entry — same non-observability note as above), and + * flows through the pipeline honoring `reloadOnSearch`. No current route + * → a synchronous plain `Error`. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Scope, ScopeEvent } from '@core/index'; +import { ngModule } from '@core/ng-module'; +import { createInjector } from '@di/injector'; +import { createModule, resetRegistry } from '@di/module'; +import type { LocationService } from '@location/index'; +import { + $RouteProvider, + ngRoute, + REDIRECT_LOOP_THRESHOLD, + RouteRedirectionLoopError, + type CompiledRouteEntry, + type Route, + type RouteParams, + type RouteService, +} from '@route/index'; + +interface Harness { + root: HTMLElement; + $route: RouteService; + $rootScope: Scope; + $location: LocationService; + $routeParams: RouteParams; + /** The recorder `$exceptionHandler` — MUST stay silent on every Slice-7 path. */ + handler: ReturnType; +} + +/** + * Register an `'app'` module whose config block receives `$routeProvider`, + * install the RECORDER `$exceptionHandler` (last-wins `.factory`), build the + * injector, seed the jsdom hash BEFORE `$route` is injected (the lazy + * location-listener contract from `route.test.ts`), and compile + link a + * root carrying ``. Deps stay `[]` — the module OBJECTS are passed + * directly so a neighbouring `resetRegistry()` cannot evict them. + */ +function boot(configure: (routeProvider: $RouteProvider) => void, initialHash: string): Harness { + const handler = vi.fn(); + const app = createModule('app', []).config(['$routeProvider', configure]); + app.factory('$exceptionHandler', [() => handler]); + const injector = createInjector([ngModule, ngRoute, app]); + window.location.hash = initialHash; + const $route = injector.get('$route'); + const $rootScope = injector.get('$rootScope'); + const $location = injector.get('$location'); + const $routeParams = injector.get('$routeParams'); + const root = document.createElement('div'); + root.innerHTML = ''; + injector.get('$compile')(root)($rootScope); + return { root, $route, $rootScope, $location, $routeParams, handler }; +} + +/** Start a navigation and run one pipeline digest. */ +function navigate(harness: Harness, path: string): void { + harness.$location.path(path); + harness.$rootScope.$digest(); +} + +interface RecordedRouteEvent { + name: string; + next: Route | undefined; + previous: Route | undefined; + rejection: unknown; +} + +/** Record every Start / Success / Error / Update broadcast in fire order. */ +function recordRouteEvents($rootScope: Scope): RecordedRouteEvent[] { + const events: RecordedRouteEvent[] = []; + const listener = (event: ScopeEvent, ...args: unknown[]): void => { + events.push({ + name: event.name, + next: args[0] as Route | undefined, + previous: args[1] as Route | undefined, + rejection: args[2], + }); + }; + $rootScope.$on('$routeChangeStart', listener); + $rootScope.$on('$routeChangeSuccess', listener); + $rootScope.$on('$routeChangeError', listener); + $rootScope.$on('$routeUpdate', listener); + return events; +} + +/** Indexed-access helper — `routes[key]` is `T | undefined` under `noUncheckedIndexedAccess`. */ +function getEntry($route: RouteService, key: string): CompiledRouteEntry { + const entry = $route.routes[key]; + if (entry === undefined) { + throw new Error(`route table has no entry for ${key}`); + } + return entry; +} + +afterEach(() => { + resetRegistry(); + // Keep the shared jsdom URL clean for neighbouring tests — the digest + // flushes `$location` mutations into the real `window.location.hash`. + window.location.hash = ''; +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R8 — static string redirect +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — static redirectTo (R8)', () => { + it('initial load onto a redirecting route: ONE digest completes both hops; events fire ONLY for the target', () => { + const harness = boot((p) => { + p.when('/old', { redirectTo: '/new' }).when('/new', { which: 'new', template: '

New

' }); + }, '#!/old'); + const events = recordRouteEvents(harness.$rootScope); + + harness.$rootScope.$digest(); + + // Address bar shows the destination; current IS the destination route. + expect(harness.$location.path()).toBe('/new'); + expect(harness.$route.current?.which).toBe('new'); + expect(harness.$route.current?.$$route).toBe(getEntry(harness.$route, '/new')); + expect(harness.root.querySelector('.new')?.textContent).toBe('New'); + + // The redirect pass emitted NO events — Start/Success carry the TARGET + // route only, and no event ever references the /old definition. + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + for (const event of events) { + expect(event.next?.$$route).toBe(getEntry(harness.$route, '/new')); + } + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('LATER navigation onto a redirecting route: digest 1 writes the redirect (no events, current untouched), digest 2 commits the target', () => { + const harness = boot((p) => { + p.when('/safe', { which: 'safe', template: '

Safe

' }) + .when('/old', { redirectTo: '/new' }) + .when('/new', { which: 'new', template: '

New

' }); + }, '#!/safe'); + harness.$rootScope.$digest(); + const previous = harness.$route.current; + expect(previous?.which).toBe('safe'); + + const events = recordRouteEvents(harness.$rootScope); + harness.$location.path('/old'); + harness.$rootScope.$digest(); + + // Digest 1: the redirect pass rewrote $location but emitted NO route + // events and never touched current — the deliberate event divergence. + expect(harness.$location.path()).toBe('/new'); + expect(events).toEqual([]); + expect(harness.$route.current).toBe(previous); + + harness.$rootScope.$digest(); + + // Digest 2: the target URL's pass fires Start/Success for the TARGET. + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(events[0]?.next?.$$route).toBe(getEntry(harness.$route, '/new')); + expect(events[0]?.previous).toBe(previous); + expect(harness.$route.current?.which).toBe('new'); + expect(harness.root.querySelector('.new')).not.toBeNull(); + expect(harness.root.querySelector('.safe')).toBeNull(); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('param-carrying redirect: /u/42?tab=info lands on /users/42?tab=info — path keys consumed, query leftovers survive', () => { + const harness = boot((p) => { + p.when('/u/:id', { redirectTo: '/users/:id' }).when('/users/:id', { + which: 'users', + template: '

User

', + }); + }, '#!/u/42?tab=info'); + + harness.$rootScope.$digest(); + + expect(harness.$location.path()).toBe('/users/42'); + // Interpolation consumed `id`; `.search(...)` carried only the leftover. + expect(harness.$location.search()).toEqual({ tab: 'info' }); + expect(harness.$route.current?.which).toBe('users'); + expect(harness.$route.current?.params).toEqual({ id: '42', tab: 'info' }); + expect(harness.$route.current?.pathParams).toEqual({ id: '42' }); + expect(harness.root.querySelector('.user')).not.toBeNull(); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R8 — function redirectTo +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — function redirectTo (R8)', () => { + it('receives (pathParams, path, search) and its return lands via $location.url(...)', () => { + let captured: { pathParams: Record; path: string; search: RouteParams } | undefined; + const harness = boot((p) => { + p.when('/from/:id', { + redirectTo: (pathParams, path, search) => { + captured = { pathParams, path, search }; + return `/target/${pathParams.id ?? ''}`; + }, + }).when('/target/:id', { which: 'target', template: '

Target

' }); + }, '#!/from/7?x=1'); + + harness.$rootScope.$digest(); + + expect(captured).toEqual({ pathParams: { id: '7' }, path: '/from/7', search: { x: '1' } }); + expect(harness.$location.path()).toBe('/target/7'); + expect(harness.$route.current?.which).toBe('target'); + expect(harness.$route.current?.pathParams).toEqual({ id: '7' }); + expect(harness.root.querySelector('.target')).not.toBeNull(); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('returning undefined means NO redirect — the route itself commits and renders', () => { + let calls = 0; + const harness = boot((p) => { + p.when('/stay', { + which: 'stay', + template: '

Stayed

', + redirectTo: () => { + calls += 1; + return undefined; + }, + }); + }, '#!/stay'); + const events = recordRouteEvents(harness.$rootScope); + + harness.$rootScope.$digest(); + + expect(calls).toBe(1); + expect(harness.$location.path()).toBe('/stay'); + expect(harness.$route.current?.which).toBe('stay'); + expect(harness.root.querySelector('.stay')?.textContent).toBe('Stayed'); + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R8 — redirect-loop guard + the same-URL escape +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — redirect-loop guard', () => { + it('exports the pinned threshold', () => { + expect(REDIRECT_LOOP_THRESHOLD).toBe(10); + }); + + it('/a→/b→/a fires exactly ONE $routeChangeError with a RouteRedirectionLoopError, leaves $location stable, and a later real navigation recovers', () => { + const harness = boot((p) => { + p.when('/safe', { which: 'safe', template: '

Safe

' }) + .when('/a', { redirectTo: '/b' }) + .when('/b', { redirectTo: '/a' }) + .when('/good', { which: 'good', template: '

Good

' }); + }, '#!/safe'); + harness.$rootScope.$digest(); + const committed = harness.$route.current; + expect(committed?.which).toBe('safe'); + + const events = recordRouteEvents(harness.$rootScope); + harness.$location.path('/a'); + // Bounded drive: one redirect hop per digest after the first — the loop + // error must fire within REDIRECT_LOOP_THRESHOLD (+ slack) digests. + for (let i = 0; i < REDIRECT_LOOP_THRESHOLD + 5; i += 1) { + harness.$rootScope.$digest(); + } + + const errors = events.filter((e) => e.name === '$routeChangeError'); + expect(errors).toHaveLength(1); + expect(errors[0]?.rejection).toBeInstanceOf(RouteRedirectionLoopError); + expect(errors[0]?.previous).toBe(committed); + // No Start/Success ever fired — every pass was a redirect hop until the + // guard tripped; current stays the last committed route. + expect(events.filter((e) => e.name !== '$routeChangeError')).toEqual([]); + expect(harness.$route.current).toBe(committed); + expect(harness.root.querySelector('.safe')).not.toBeNull(); + + // $location is NOT written again — the digest settled; further digests + // are silent (no new events, URL stable). + const settledUrl = harness.$location.url(); + harness.$rootScope.$digest(); + harness.$rootScope.$digest(); + expect(harness.$location.url()).toBe(settledUrl); + expect(events.filter((e) => e.name === '$routeChangeError')).toHaveLength(1); + + // A subsequent REAL navigation works normally. + navigate(harness, '/good'); + expect(harness.$route.current?.which).toBe('good'); + expect(harness.root.querySelector('.good')?.textContent).toBe('Good'); + expect(events.map((e) => e.name)).toEqual(['$routeChangeError', '$routeChangeStart', '$routeChangeSuccess']); + // The loop error travels via the broadcast channel only — never the handler. + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('self-redirect /self→/self composes to the CURRENT URL and falls through — the route commits normally (no loop, no error)', () => { + const harness = boot((p) => { + p.when('/self', { which: 'self', redirectTo: '/self', template: '

Self

' }); + }, '#!/self'); + const events = recordRouteEvents(harness.$rootScope); + + harness.$rootScope.$digest(); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(harness.$route.current?.which).toBe('self'); + expect(harness.$location.path()).toBe('/self'); + expect(harness.root.querySelector('.self')?.textContent).toBe('Self'); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R14 / R10 — $routeUpdate (update-only navigations) +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — $routeUpdate (R14)', () => { + it('reloadOnSearch:false + query-only change → $routeUpdate(current), NO Start/Success, view persists (same DOM node, controller NOT re-run), params updated in place', () => { + let ctorCount = 0; + const harness = boot((p) => { + p.when('/users/:id', { + reloadOnSearch: false, + template: '

User

', + controller: [ + () => { + ctorCount += 1; + }, + ], + }); + }, '#!/users/1?page=1'); + harness.$rootScope.$digest(); + const before = harness.$route.current; + const nodeBefore = harness.root.querySelector('.u'); + expect(ctorCount).toBe(1); + expect(nodeBefore).not.toBeNull(); + expect(before?.params).toEqual({ id: '1', page: '1' }); + + const events = recordRouteEvents(harness.$rootScope); + harness.$location.search({ page: '2' }); + harness.$rootScope.$digest(); + + // ONE $routeUpdate carrying the (same-reference) current route — no + // Start, no Success, so ngView never re-rendered. + expect(events.map((e) => e.name)).toEqual(['$routeUpdate']); + expect(events[0]?.next).toBe(before); + expect(harness.$route.current).toBe(before); + expect(ctorCount).toBe(1); + expect(harness.root.querySelector('.u')).toBe(nodeBefore); + // params + $routeParams updated in place. + expect(harness.$route.current?.params).toEqual({ id: '1', page: '2' }); + expect(harness.$routeParams).toEqual({ id: '1', page: '2' }); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('reloadOnSearch:false does NOT cover a PATH change — /users/1 → /users/2 fully rebuilds', () => { + let ctorCount = 0; + const harness = boot((p) => { + p.when('/users/:id', { + reloadOnSearch: false, + template: '

User

', + controller: [ + () => { + ctorCount += 1; + }, + ], + }); + }, '#!/users/1'); + harness.$rootScope.$digest(); + const before = harness.$route.current; + expect(ctorCount).toBe(1); + + const events = recordRouteEvents(harness.$rootScope); + navigate(harness, '/users/2'); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(harness.$route.current).not.toBe(before); + expect(harness.$route.current?.pathParams).toEqual({ id: '2' }); + expect(ctorCount).toBe(2); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('reloadOnUrl:false → a path-param change within the same route is ALSO update-only; pathParams stays stale (documented upstream parity)', () => { + let ctorCount = 0; + const harness = boot((p) => { + p.when('/things/:id', { + reloadOnUrl: false, + template: '

Thing

', + controller: [ + () => { + ctorCount += 1; + }, + ], + }); + }, '#!/things/1'); + harness.$rootScope.$digest(); + const before = harness.$route.current; + expect(ctorCount).toBe(1); + + const events = recordRouteEvents(harness.$rootScope); + navigate(harness, '/things/2'); + + expect(events.map((e) => e.name)).toEqual(['$routeUpdate']); + expect(harness.$route.current).toBe(before); + expect(ctorCount).toBe(1); + // Only `params` is copied on the update branch (upstream commitRoute); + // `pathParams` deliberately keeps the value from the original commit. + expect(harness.$route.current?.params).toEqual({ id: '2' }); + expect(harness.$routeParams).toEqual({ id: '2' }); + expect(harness.$route.current?.pathParams).toEqual({ id: '1' }); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// R13 — reload() +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — reload() (R13)', () => { + it('re-runs resolve, constructs a fresh controller, and re-fires Success', () => { + let resolveCount = 0; + let ctorCount = 0; + const harness = boot((p) => { + p.when('/data', { + template: '

Data

', + resolve: { + data: [ + () => { + resolveCount += 1; + return `v${String(resolveCount)}`; + }, + ], + }, + controller: [ + () => { + ctorCount += 1; + }, + ], + }); + }, '#!/data'); + harness.$rootScope.$digest(); + const before = harness.$route.current; + expect(resolveCount).toBe(1); + expect(ctorCount).toBe(1); + expect(before?.locals?.data).toBe('v1'); + + const events = recordRouteEvents(harness.$rootScope); + harness.$route.reload(); + // Scheduled via $evalAsync — nothing happens until a digest drains it. + expect(events).toEqual([]); + harness.$rootScope.$digest(); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(resolveCount).toBe(2); + expect(ctorCount).toBe(2); + const after = harness.$route.current; + expect(after).not.toBe(before); + expect(after?.$$route).toBe(before?.$$route); + expect(after?.locals?.data).toBe('v2'); + expect(harness.root.querySelector('.d')).not.toBeNull(); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('bypasses the update-only branch on a reloadOnSearch:false route — full rebuild, no $routeUpdate', () => { + let ctorCount = 0; + const harness = boot((p) => { + p.when('/keep', { + reloadOnSearch: false, + template: '

Keep

', + controller: [ + () => { + ctorCount += 1; + }, + ], + }); + }, '#!/keep'); + harness.$rootScope.$digest(); + const before = harness.$route.current; + const nodeBefore = harness.root.querySelector('.k'); + expect(ctorCount).toBe(1); + + const events = recordRouteEvents(harness.$rootScope); + harness.$route.reload(); + harness.$rootScope.$digest(); + + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(events.filter((e) => e.name === '$routeUpdate')).toEqual([]); + expect(harness.$route.current).not.toBe(before); + expect(ctorCount).toBe(2); + expect(harness.root.querySelector('.k')).not.toBe(nodeBefore); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// updateParams +// ──────────────────────────────────────────────────────────────────────────── + +describe('$route — updateParams()', () => { + it('writes path + leftover search from the merged params and flows through the full pipeline', () => { + const harness = boot((p) => { + p.when('/users/:id', { which: 'users', template: '

User

' }); + }, '#!/users/42'); + harness.$rootScope.$digest(); + const before = harness.$route.current; + expect(before?.params).toEqual({ id: '42' }); + + const events = recordRouteEvents(harness.$rootScope); + harness.$route.updateParams({ id: '43', tab: 'x' }); + harness.$rootScope.$digest(); + + // merged = { ...current.params, ...newParams }; interpolation consumed + // `id` into the path, the leftover `tab` became the query. + expect(harness.$location.path()).toBe('/users/43'); + expect(harness.$location.search()).toEqual({ tab: 'x' }); + // Default reload semantics → a normal full navigation. + expect(events.map((e) => e.name)).toEqual(['$routeChangeStart', '$routeChangeSuccess']); + expect(harness.$route.current).not.toBe(before); + expect(harness.$route.current?.params).toEqual({ id: '43', tab: 'x' }); + expect(harness.$route.current?.pathParams).toEqual({ id: '43' }); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('honors reloadOnSearch:false — a search-only updateParams takes the $routeUpdate branch, not a rebuild', () => { + let ctorCount = 0; + const harness = boot((p) => { + p.when('/users/:id', { + reloadOnSearch: false, + template: '

User

', + controller: [ + () => { + ctorCount += 1; + }, + ], + }); + }, '#!/users/42'); + harness.$rootScope.$digest(); + const before = harness.$route.current; + expect(ctorCount).toBe(1); + + const events = recordRouteEvents(harness.$rootScope); + harness.$route.updateParams({ tab: 'y' }); + harness.$rootScope.$digest(); + + // merged = { id: '42', tab: 'y' } → path unchanged, only the query + // moved → the update-only branch fires. + expect(harness.$location.path()).toBe('/users/42'); + expect(harness.$location.search()).toEqual({ tab: 'y' }); + expect(events.map((e) => e.name)).toEqual(['$routeUpdate']); + expect(harness.$route.current).toBe(before); + expect(ctorCount).toBe(1); + expect(harness.$route.current?.params).toEqual({ id: '42', tab: 'y' }); + expect(harness.$routeParams).toEqual({ id: '42', tab: 'y' }); + expect(harness.handler).not.toHaveBeenCalled(); + }); + + it('throws synchronously when no route is matched', () => { + const harness = boot((p) => { + p.when('/never', {}); + }, '#!/elsewhere'); + harness.$rootScope.$digest(); + expect(harness.$route.current).toBeUndefined(); + expect(() => { + harness.$route.updateParams({ id: '1' }); + }).toThrow(new Error('Route parameters cannot be changed: route is not matched')); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// trailing-slash tolerance for AUTHORED trailing slashes (Slice-7 fix) +// ──────────────────────────────────────────────────────────────────────────── + +describe("$route — pattern authored with a trailing slash ('/list/')", () => { + it('matches BOTH /list and /list/', () => { + const harness = boot((p) => { + p.when('/list/', { which: 'list', template: '

List

' }); + }, '#!/list'); + harness.$rootScope.$digest(); + expect(harness.$route.current?.which).toBe('list'); + expect(harness.root.querySelector('.l')).not.toBeNull(); + + // The slashed form matches too (a path change on the same route — + // default reload semantics rebuild, which is fine here). + navigate(harness, '/list/'); + expect(harness.$route.current?.which).toBe('list'); + expect(harness.$route.current?.$$route).toBe(getEntry(harness.$route, '/list/')); + + // Sanity: the pattern did not become a catch-all. + navigate(harness, '/list/extra'); + expect(harness.$route.current).toBeUndefined(); + expect(harness.handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/route/__tests__/route.test.ts b/src/route/__tests__/route.test.ts index f1e45f4..86e1a54 100644 --- a/src/route/__tests__/route.test.ts +++ b/src/route/__tests__/route.test.ts @@ -289,16 +289,21 @@ describe('$route — otherwise fallback (R2)', () => { expect(getEntry($route, 'null').template).toBe('x'); }); - it("otherwise('/home') string shorthand stores { redirectTo } — matched as fallback, redirect NOT run this slice", () => { + it("otherwise('/home') string shorthand REDIRECTS the unmatched URL to /home (R8 — Slice 7)", () => { const { $route, $rootScope, $location } = boot((p) => { - p.when('/a', {}).otherwise('/home'); + p.when('/home', { which: 'home' }).otherwise('/home'); }, '#!/missing'); $rootScope.$digest(); expect(getEntry($route, OTHERWISE_ROUTE_KEY).redirectTo).toBe('/home'); - // Slice 3: the redirect is stored but does not run — the fallback entry - // itself becomes current and the URL stays put. - expect($route.current?.redirectTo).toBe('/home'); - expect($location.path()).toBe('/missing'); + // Slice 7: the stored redirect now RUNS — the address bar shows the + // destination and the destination route commits (the redirect pass + // itself emits no route events and never becomes `current`). The + // initial digest completes both hops: the first location-watch fire is + // inherently dirty, so the redirected URL is processed on the second + // iteration of the SAME digest. + expect($location.path()).toBe('/home'); + expect($route.current?.which).toBe('home'); + expect($route.current?.redirectTo).toBeUndefined(); }); it('a registered route still wins over the otherwise fallback', () => { diff --git a/src/route/index.ts b/src/route/index.ts index 806fb5e..f8d726c 100644 --- a/src/route/index.ts +++ b/src/route/index.ts @@ -16,6 +16,7 @@ export { createRoute, OTHERWISE_ROUTE_KEY } from './route'; export type { CreateRouteArgs } from './route'; +export { REDIRECT_LOOP_THRESHOLD, RouteRedirectionLoopError } from './route-error'; export { $RouteProvider } from './route-provider'; export { ngRoute } from './ng-route-module'; export type { diff --git a/src/route/route-error.ts b/src/route/route-error.ts new file mode 100644 index 0000000..85b3c0f --- /dev/null +++ b/src/route/route-error.ts @@ -0,0 +1,52 @@ +/** + * Typed error classes for the `ngRoute` module (spec 040 Slice 7 / + * technical-considerations §2.6). + * + * Mirrors `src/bootstrap/bootstrap-error.ts`: each class carries a literal + * `name` brand so callers can narrow with `err instanceof ` instead of + * string-matching the message. + * + * Runtime routing failures are delivered through the `$routeChangeError` + * broadcast — the event IS the channel; nothing here adds an + * `EXCEPTION_HANDLER_CAUSES` token (the tuple stays at 13). + */ + +/** + * Broadcast as the `$routeChangeError` payload when the `redirectTo` + * stage detects a redirect CYCLE (FS R8; e.g. `/a → /b → /a`, or a + * self-redirect that keeps producing a new URL): `$route` counts + * CONSECUTIVE redirect passes that never reach a committed route and, past + * {@link REDIRECT_LOOP_THRESHOLD} of them, constructs this error, + * broadcasts `$routeChangeError(next, previous, err)`, and STOPS + * redirecting — `$location` is NOT written again, so the digest settles on + * the last redirecting URL and `$route.current` stays whatever was last + * committed. + * + * Upstream AngularJS has no such guard — a `redirectTo` cycle re-fires the + * location watch forever (one hop per digest). The threshold guard is a + * deliberate hardening addition. + */ +export class RouteRedirectionLoopError extends Error { + readonly name = 'RouteRedirectionLoopError' as const; + + constructor(count: number, trail: readonly string[]) { + super( + `Detected ${String(count)} consecutive route redirects without a committed route — ` + + `aborting a likely redirectTo loop (trail: ${trail.join(' -> ')})`, + ); + } +} + +/** + * How many CONSECUTIVE redirect passes (no committed route in between) the + * pipeline tolerates before it reports a {@link RouteRedirectionLoopError} + * and stops writing `$location`. + * + * Why 10: each redirect hop is processed on its own location-watch fire — + * after the first digest a loop consumes ONE hop per `$digest()` call, so + * the digest TTL (also 10, but scoped to a single digest) never trips on + * its own. Ten hops is far beyond any legitimate redirect chain + * (multi-step canonicalization is typically 1–2 hops) while still catching + * the cycle within a bounded number of digests. + */ +export const REDIRECT_LOOP_THRESHOLD = 10; diff --git a/src/route/route-path.ts b/src/route/route-path.ts index f260d78..94c4424 100644 --- a/src/route/route-path.ts +++ b/src/route/route-path.ts @@ -22,6 +22,12 @@ * companion redirect route per pattern; we fold it into the regexp * instead — a documented mechanism divergence with identical observable * matching, minus the extra table entries and redirect hops. + * - A pattern AUTHORED with a trailing slash (`'/users/'`) is normalized by + * stripping ONE trailing `/` before compiling (`'/'` itself is kept + * intact), so the compiled `^…/?$` form matches BOTH `/users` and + * `/users/`. Without the strip the source would end `…//?$` — matching + * only the slashed form (the Slice-3 known gap). Upstream's companion + * `{ redirectTo }` route achieves the same tolerance via a redirect hop. * - `caseInsensitiveMatch` → the `i` flag (FS R3). */ @@ -55,7 +61,12 @@ export interface PathRegExpOptions { export function pathRegExp(pattern: string, opts: PathRegExpOptions): CompiledPath { const keys: RoutePathKey[] = []; - const source = pattern + // Trailing-slash normalization (file header): strip ONE authored trailing + // slash — the appended `/?` then supplies the tolerance in BOTH + // directions. `'/'` itself is kept intact. + const normalized = pattern.length > 1 && pattern.endsWith('/') ? pattern.slice(0, -1) : pattern; + + const source = normalized .replace(/([().])/g, '\\$1') .replace( /(\/)?:(\w+)(\*\?|[?*])?/g, diff --git a/src/route/route-redirect.ts b/src/route/route-redirect.ts new file mode 100644 index 0000000..a149c35 --- /dev/null +++ b/src/route/route-redirect.ts @@ -0,0 +1,62 @@ +/** + * Redirect-template interpolation (spec 040 Slice 7) — INTERNAL to `@route` + * (not barrel-exported; the `route-path.ts` precedent). + * + * {@link interpolateRedirect} is a direct port of upstream `$route`'s + * private `interpolate(string, params)` helper (route.js), shared by BOTH + * consumers exactly as upstream shares it: + * + * - the string form of `redirectTo` — `'/users/:id'` interpolated against + * `next.params`, and + * - `$route.updateParams(newParams)` — the current route's `originalPath` + * interpolated against `{ ...current.params, ...newParams }`. + * + * Upstream semantics, pinned: + * + * - The template splits on `':'`; segment 0 is literal, every later segment + * matches `/(\w+)(?:[?*])?(.*)/` — the leading word chars are the param + * KEY, an optional `?` / `*` modifier is consumed and DISCARDED, the rest + * of the segment is literal tail. + * - Each consumed key is **deleted from `params`** (deliberate mutation — + * upstream comment: "interpolate modifies newParams, only query params + * are left"), so the caller's follow-up `$location.search(params)` writes + * only the LEFTOVER (query) values. + * - A key missing from `params` renders as `''` (upstream pushes + * `undefined` into the result array and `Array#join` renders it empty). + * - An array value renders comma-joined (`String` coercion — upstream's + * `join('')` coerces identically). + * + * DIVERGENCE (defensive): a `:` followed by no word char (`'/a/::b'`, a + * trailing `':'`) crashes upstream with a `TypeError` (its unguarded + * `segmentMatch[1]`); here the segment is re-emitted verbatim + * (`':' + segment`) instead — garbage in, garbage out, no throw. + */ + +import type { RouteParams } from './route-types'; + +/** + * Interpolate a route-param template (`'/users/:id'` style) against + * `params`, CONSUMING (deleting) every key the path used — see the file + * header for the pinned upstream semantics. + */ +export function interpolateRedirect(template: string, params: RouteParams): string { + const result: string[] = []; + template.split(':').forEach((segment, index) => { + if (index === 0) { + result.push(segment); + return; + } + const segmentMatch = /^(\w+)(?:[?*])?(.*)$/.exec(segment); + const key = segmentMatch?.[1]; + if (segmentMatch === null || key === undefined) { + result.push(':' + segment); // defensive divergence — see file header + return; + } + const value = params[key]; + result.push(value === undefined ? '' : String(value)); + result.push(segmentMatch[2] ?? ''); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- upstream `interpolate` consumes the used key so the caller's `.search(params)` carries only the leftover query values; keys are route-param names off the compiled pattern, not arbitrary input + delete params[key]; + }); + return result.join(''); +} diff --git a/src/route/route-types.ts b/src/route/route-types.ts index 07e192b..771c9cd 100644 --- a/src/route/route-types.ts +++ b/src/route/route-types.ts @@ -66,16 +66,37 @@ export interface RouteDefinition { /** `controllerAs` alias for the route controller (consumed by the ngView slice). */ controllerAs?: string; /** - * Redirect target — a path string or a - * `(pathParams, path, search) => string` function (consumed by the - * redirect slice). + * Redirect target (FS R8). A STRING is a route-param template + * interpolated against the matched route's `params` + * (`'/users/:id'` style; consumed path keys drop out, the leftovers + * become the query string). A FUNCTION is called with + * `(pathParams, path, search)` (upstream signature — the CURRENT + * `$location.path()` / `.search()`) and returns the target URL for + * `$location.url(target)`; returning `undefined` means "no redirect" and + * the route processes normally. Either form writes `$location` with + * `.replace()` (no intermediate history entry) and the pass emits NO + * route events — the location change triggers a fresh pipeline pass + * against the target. */ - redirectTo?: string | ((pathParams: Record, path: string, search: RouteParams) => string); + redirectTo?: string | ((pathParams: Record, path: string, search: RouteParams) => string | undefined); /** Map of dependencies to pre-load before the route activates (consumed by the resolve slice). */ resolve?: Record; - /** Rebuild the screen on query-only URL changes. Default `true` (normalized by `when()`). */ + /** + * Rebuild the screen on query-only URL changes. Default `true` + * (normalized by `when()`). When `false`, a same-route navigation whose + * PATH params are unchanged (only `$location.search()` differs) skips + * the teardown: `current.params` / `$routeParams` update in place and + * `$routeUpdate` is broadcast instead of Start/Success (FS R14 / R10). + * Has no effect when `reloadOnUrl` is `false` (that flag already covers + * every same-route change — upstream 1.7+ precedence). + */ reloadOnSearch?: boolean; - /** Rebuild the screen on any URL change to the same route. Default `true` (normalized by `when()`). */ + /** + * Rebuild the screen on ANY URL change to the same route. Default `true` + * (normalized by `when()`). When `false`, even PATH-param changes within + * the SAME route definition take the `$routeUpdate` update-in-place path + * — no teardown, no Start/Success (upstream 1.7+ semantics). + */ reloadOnUrl?: boolean; /** Match the pattern ignoring letter case. Default `false`. */ caseInsensitiveMatch?: boolean; @@ -151,7 +172,24 @@ export interface RouteService { /** * Re-run the current route on demand (FS R13). Schedules the update via * `$rootScope.$evalAsync` (upstream mechanism), so it lands on the next - * digest tick. + * digest tick. The re-run is a FULL rebuild: the `forceReload` flag + * bypasses the `reloadOnSearch` / `reloadOnUrl` → `$routeUpdate` + * short-circuit, so `resolve` entries re-invoke, the template resolves + * again (`$templateRequest` serves cached bodies without a refetch), a + * fresh `current` object commits, and `ngView` rebuilds the screen with + * a new scope + controller on the resulting `$routeChangeSuccess`. */ reload(): void; + /** + * Change the current route's parameters in place on the URL (upstream + * `$route#updateParams`): merges `newParams` OVER `current.params`, + * interpolates the route's `originalPath` with the merged map (path + * placeholders consume their keys), writes `$location.path(...)`, and + * puts every LEFTOVER key into `$location.search(...)`. The resulting + * navigation flows through the normal pipeline on the next digest — + * `reloadOnSearch` / `reloadOnUrl` semantics apply as usual. Throws a + * plain `Error` when no route is currently matched (upstream + * `$route:norout`). + */ + updateParams(newParams: RouteParams): void; } diff --git a/src/route/route.ts b/src/route/route.ts index b63a1c8..b31c04e 100644 --- a/src/route/route.ts +++ b/src/route/route.ts @@ -77,18 +77,77 @@ * `$routeChangeSuccess(undefined, previous)`) and `$route.current` becomes * `undefined` / `$routeParams` empties. * - * `reload()` sets the `forceReload` flag and schedules the update via - * `$rootScope.$evalAsync(update)` (the upstream mechanism) — the flag is - * consumed by the `reloadOnSearch` / `$routeUpdate` short-circuit a later - * slice adds; this slice rebuilds on every pass regardless. + * SLICE-7 STAGES (redirects / reload semantics / params update): + * + * - **Update-only navigations (FS R14 / R10)** — FIRST, before the silent + * bail and before any broadcast: when the new match maps to the SAME + * route definition as `current` (`$$route` identity) and the route opted + * out of rebuilding (`reloadOnUrl: false` — any same-route change; OR + * `reloadOnSearch: false` AND the path params are `isEqual`-unchanged — + * query-only change), the pass updates `current.params` + + * `$routeParams` IN PLACE and broadcasts `$routeUpdate(current)` — no + * Start/Success, no teardown; `ngView` reacts only to Success, so the + * screen persists BY DESIGN (upstream `isNavigationUpdateOnly` + + * `commitRoute`'s update branch, incl. leaving `current.pathParams` + * stale on a `reloadOnUrl: false` path change — upstream copies only + * `params`). `forceReload` (set by `reload()`) bypasses this branch. + * + * - **`redirectTo` (FS R8)** — AFTER the update-only check, BEFORE + * `$routeChangeStart` (technical-considerations §2.4 order; upstream 1.8 + * broadcasts Start for the redirect pass too — this project's pass emits + * NO route events, a documented divergence). String form: + * `$location.path(interpolateRedirect(redirectTo, next.params)) + * .search(next.params).replace()` — the interpolation CONSUMES the path + * keys so `.search(...)` carries only the leftovers. Function form: + * `redirectTo(next.pathParams, $location.path(), $location.search())` → + * `$location.url(result).replace()`; an `undefined` return means "no + * redirect" (upstream `isDefined` gate). Both mark `.replace()` so the + * intermediate URL leaves no history entry. When the write actually + * CHANGED the composed URL the pass returns — the location watch fires a + * fresh `$locationChangeSuccess` that runs the pipeline against the + * target; an UNCHANGED URL falls through and processes the route + * normally (upstream `handlePossibleRedirection`'s `newUrl !== oldUrl` + * check — the natural self-redirect-to-same-URL escape). A redirect pass + * bumps the staleness token (it supersedes any in-flight async + * navigation, mirroring upstream's `$route.current` reassignment). + * + * - **Redirect-loop guard (§2.6)** — a closure counter tracks CONSECUTIVE + * redirect passes; every pass that reaches the commit attempt (or takes + * the update-only branch) resets it. Past `REDIRECT_LOOP_THRESHOLD` + * consecutive hops the pass broadcasts + * `$routeChangeError(next, previous, RouteRedirectionLoopError)` and + * does NOT write `$location` again — the digest settles (no new + * location event fires, so the loop is dead until the next real + * navigation). No `EXCEPTION_HANDLER_CAUSES` token — the error event IS + * the channel. Upstream has NO loop guard (a cycle hops forever, one + * redirect per digest) — deliberate hardening divergence. + * + * - **`reload()` (FS R13)** — sets `forceReload` and schedules + * `$rootScope.$evalAsync(update)` (upstream mechanism). The flag's ONLY + * consumer is the update-only branch above, so the forced pass always + * takes the full rebuild path — fresh `resolve` invocations, template + * re-resolution, a fresh `current`, and a Success that makes `ngView` + * rebuild. The flag clears on every full-navigation pass (upstream + * `commitRoute`'s `forceReload = false`), covering both the sync and + * async arms — neither arm re-reads it. + * + * - **`updateParams(newParams)` (upstream parity)** — merges `newParams` + * over `current.params`, interpolates the route's `originalPath` with + * the merged map (consuming path keys), writes `$location.path(...)` and + * puts the leftovers into `$location.search(...)`; NO `.replace()` — a + * normal history entry, and the navigation flows through this same + * pipeline on the next digest. Throws a plain `Error` when no route is + * matched (upstream `norout`). */ import type { QPromise, QService } from '@async/index'; -import type { Scope } from '@core/index'; +import { isEqual, type Scope } from '@core/index'; import type { Injector, Invokable } from '@di/di-types'; import type { LocationService } from '@location/index'; import type { TemplateRequestFn } from '@template/index'; +import { REDIRECT_LOOP_THRESHOLD, RouteRedirectionLoopError } from './route-error'; import { matchRoute } from './route-path'; +import { interpolateRedirect } from './route-redirect'; import { hasInlineTemplate, hasTemplateUrl, resolveInlineTemplate, resolveTemplateUrl } from './route-template'; import type { CompiledRouteEntry, Route, RouteParams, RouteService } from './route-types'; @@ -150,6 +209,16 @@ export function createRoute(args: CreateRouteArgs): RouteService { */ let latestNavigationToken: object = {}; + /** + * The redirect-loop guard state (file header): `consecutiveRedirects` + * counts uninterrupted redirect passes; `redirectTrail` carries the + * visited target URLs for the error message. Both reset on every pass + * that reaches a commit attempt / update-only branch, and when the loop + * error fires. + */ + let consecutiveRedirects = 0; + let redirectTrail: string[] = []; + const $route: RouteService = { routes, current: undefined, @@ -157,6 +226,22 @@ export function createRoute(args: CreateRouteArgs): RouteService { forceReload = true; rootScope.$evalAsync(update); }, + updateParams(newParams: RouteParams): void { + const current = $route.current; + const definition = current?.$$route; + if (current === undefined || definition === undefined) { + // Programmer error surfaced synchronously to the caller (upstream + // `$routeMinErr('norout', 'Tried updating route with no current route')`). + throw new Error('Route parameters cannot be changed: route is not matched'); + } + // Merge OVER the current params; interpolation CONSUMES the + // path-placeholder keys, so `.search(...)` gets only the leftovers + // (upstream comment: "interpolate modifies newParams, only query + // params are left"). NO `.replace()` — a normal history entry. + const merged: RouteParams = { ...current.params, ...newParams }; + location.path(interpolateRedirect(definition.originalPath ?? '', merged)); + location.search(merged); + }, }; /** @@ -186,6 +271,22 @@ export function createRoute(args: CreateRouteArgs): RouteService { return fallback === undefined ? undefined : { ...fallback, params: {}, pathParams: {}, $$route: fallback }; } + /** + * Repopulate the injected `$routeParams` object IN PLACE so references + * injected elsewhere stay live (upstream `$RouteParamsProvider` pattern): + * clear own keys, copy the new params. Shared by `commit` and the + * `$routeUpdate` update-only branch. + */ + function repopulateRouteParams(params: RouteParams | undefined): void { + for (const key of Object.keys(routeParams)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- in-place repopulation is the $routeParams contract (injected references must observe the new values); keys are own enumerable route-param names, not arbitrary input + delete routeParams[key]; + } + if (params !== undefined) { + Object.assign(routeParams, params); + } + } + /** * Commit a navigation (file header steps 5–6): set `$route.current`, * repopulate `$routeParams` in place, broadcast `$routeChangeSuccess`. @@ -194,25 +295,100 @@ export function createRoute(args: CreateRouteArgs): RouteService { */ function commit(next: Route | undefined, previous: Route | undefined): void { $route.current = next; + repopulateRouteParams(next?.params); + rootScope.$broadcast('$routeChangeSuccess', next, previous); + } - // Repopulate $routeParams IN PLACE so injected references stay live - // (upstream $RouteParamsProvider pattern): clear own keys, copy params. - for (const key of Object.keys(routeParams)) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- in-place repopulation is the $routeParams contract (injected references must observe the new values); keys are own enumerable route-param names, not arbitrary input - delete routeParams[key]; + /** + * Upstream `isNavigationUpdateOnly` (1.8.3, pinned): NOT a forced + * reload, same route-definition identity, AND either `reloadOnUrl` is + * off (any same-route change reuses the screen) OR `reloadOnSearch` is + * off AND the path params are unchanged (query-only change). Path-param + * equality via the project's `isEqual` (upstream `angular.equals`). + */ + function isNavigationUpdateOnly(next: Route, previous: Route): boolean { + return ( + !forceReload && + next.$$route !== undefined && + next.$$route === previous.$$route && + (next.reloadOnUrl === false || (next.reloadOnSearch === false && isEqual(next.pathParams, previous.pathParams))) + ); + } + + /** + * The `redirectTo` stage (file header). Returns `true` when the pass is + * fully handled here — either `$location` was rewritten (the location + * watch will run the pipeline against the target) or the loop guard + * fired — and `false` when the route should process normally (no + * `redirectTo` outcome: a function form returned `undefined`, or the + * computed target equals the current URL). + */ + function runRedirect(next: Route, previous: Route | undefined): boolean { + const redirectTo = next.redirectTo; + + if (consecutiveRedirects >= REDIRECT_LOOP_THRESHOLD) { + const err = new RouteRedirectionLoopError(consecutiveRedirects, redirectTrail); + consecutiveRedirects = 0; + redirectTrail = []; + // Supersede any in-flight async navigation — this pass concluded + // (with an error), so a stale fetch must not commit afterwards. + latestNavigationToken = {}; + rootScope.$broadcast('$routeChangeError', next, previous, err); + return true; // STOP — $location is deliberately NOT written again } - if (next !== undefined) { - Object.assign(routeParams, next.params); + + const oldUrl = location.url(); + if (typeof redirectTo === 'string') { + // Interpolation CONSUMES the path keys from `next.params`, so + // `.search(next.params)` writes only the leftover (query) values — + // upstream `getRedirectionData` string branch. + location.path(interpolateRedirect(redirectTo, next.params)).search(next.params).replace(); + } else if (typeof redirectTo === 'function') { + const target = redirectTo(next.pathParams, location.path(), location.search()); + if (target === undefined) { + return false; // upstream `isDefined` gate — no redirect, process normally + } + location.url(target).replace(); + } else { + return false; // non-string/function junk via the index signature — inert } - rootScope.$broadcast('$routeChangeSuccess', next, previous); + if (location.url() === oldUrl) { + // Upstream `handlePossibleRedirection`: the target IS the current + // URL — no location event will ever fire, so keep processing this + // route (the self-redirect escape hatch). + return false; + } + + consecutiveRedirects += 1; + redirectTrail.push(location.url()); + // The redirect supersedes any in-flight async navigation (upstream + // reassigns `$route.current`, failing its `nextRoute === $route.current` + // staleness check; the token is this project's equivalent). + latestNavigationToken = {}; + return true; } - /** The navigation pipeline — steps 1–6 of the file header. */ + /** The navigation pipeline — steps 1–6 of the file header + the Slice-7 stages. */ function update(): void { const next = parseRoute(); const previous = $route.current; + // ── Update-only navigation (FS R14 / R10) ────────────────────────── + // Same route definition + rebuild opted out (see isNavigationUpdateOnly) + // → update `current.params` / `$routeParams` in place and broadcast + // `$routeUpdate` — NO Start/Success, no teardown; `ngView` reacts only + // to Success, so the screen persists by design. Upstream copies only + // `params` (pathParams stays as committed) — matched exactly. + if (next !== undefined && previous !== undefined && isNavigationUpdateOnly(next, previous)) { + previous.params = next.params; + repopulateRouteParams(next.params); + consecutiveRedirects = 0; + redirectTrail = []; + rootScope.$broadcast('$routeUpdate', previous); + return; + } + // Upstream `updateRoute`'s `next || last` gate: with NOTHING to leave // and NOTHING to enter (no match, no fallback, no active route) the // pass is silent — no events, no state writes. @@ -220,13 +396,23 @@ export function createRoute(args: CreateRouteArgs): RouteService { return; } - // The force flag is consumed by the `reloadOnSearch` / `$routeUpdate` - // short-circuit a later slice adds (upstream resets it on the navigate - // branch); this slice rebuilds on every pass, so it is read (the - // `void strictDi` bootstrap discard precedent) and cleared. - void forceReload; + // Full-navigation pass: consume the force flag (upstream `commitRoute`'s + // `forceReload = false` on the navigate branch). Its only reader is the + // update-only check above, so clearing here covers both the sync and + // async commit arms. forceReload = false; + // ── redirectTo stage (FS R8) — BEFORE Start, NO route events ─────── + if (next !== undefined && next.redirectTo !== undefined && runRedirect(next, previous)) { + return; // $location rewritten (or the loop guard fired) — the next + // $locationChangeSuccess runs the pipeline against the target + } + + // This pass attempts a real commit — the consecutive-redirect chain is + // broken (loop-guard reset; see the file header). + consecutiveRedirects = 0; + redirectTrail = []; + const startEvent = rootScope.$broadcast('$routeChangeStart', next, previous); if (startEvent.defaultPrevented) { return; // navigation vetoed — current route and $routeParams untouched From 9bc753304f061f757274658ddc6499ff1d3f7ef5 Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Tue, 7 Jul 2026 15:13:24 -0400 Subject: [PATCH 09/13] test+fix+docs: routing parity suites, 3 parity bug fixes, READMEs, diagram, roadmap (spec 040 slice 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CLAUDE.md | 16 + context/diagrams/README.md | 1 + context/diagrams/routing.md | 141 +++ context/product/roadmap.md | 8 +- context/spec/040-routing/tasks.md | 6 +- src/__tests__/diagrams-structure.test.ts | 1 + src/location/README.md | 167 +++ .../__tests__/location-parity.test.ts | 385 ++++++ src/route/README.md | 196 +++ src/route/__tests__/route-parity.test.ts | 1076 +++++++++++++++++ src/route/__tests__/route-path.test.ts | 35 +- src/route/ng-view.ts | 49 +- src/route/route-path.ts | 14 +- src/route/route-types.ts | 9 +- src/route/route.ts | 26 +- 15 files changed, 2088 insertions(+), 42 deletions(-) create mode 100644 context/diagrams/routing.md create mode 100644 src/location/README.md create mode 100644 src/location/__tests__/location-parity.test.ts create mode 100644 src/route/README.md create mode 100644 src/route/__tests__/route-parity.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bea2d04..44f684f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,8 @@ CI (`.github/workflows/ci.yml`) gates on: lint → format:check → typecheck | `./cache` | General-purpose cache factory (spec 038 Slice 1) — `$cacheFactory(id, options?)` produces named, `Map`-backed caches (`put` / `get` / `remove` / `removeAll` / `info` + `destroy` which detaches from the registry so the id is reusable); the factory carries a registry surface (`get` / `info`). Backs `$http`'s opt-in response cache and is usable standalone. PURE ESM-first factory (`createCacheFactory`, fresh registry closure per injector) + DI registration on `ngModule`. **No LRU / `capacity` eviction** (documented deviation — `CacheOptions.capacity` accepted for shape parity, ignored; every cache is an unbounded `Map`). | `createCacheFactory`, types `Cache`, `CacheFactory`, `CacheInfo`, `CacheOptions` | | `./http` | Networking (spec 038) — `$http` (callable returning a `$q` promise of a typed `HttpResponse`; general `$http(config)` form + `get`/`delete`/`head`/`post`/`put`/`patch`/`jsonp` shortcuts), `$httpBackend` (the transport seam — native `fetch` + a `