Fix remaining RxJS unbound-operator call sites in sidenav - #677
Conversation
Both first.call(this._ngZone.onMicrotaskEmpty) sites in MdSidenavContainer (enabling transitions after _sidenavs.changes, and re-validating drawers after onAlignChanged) used the RxJS 5 "unbound operator" pattern. Under RxJS 7, first imported from rxjs/operators is a factory that returns an OperatorFunction, so calling it with .call(observable) returns a function, not an Observable -- the chained .subscribe(...) then throws TypeError at runtime. Unlike the three sites already fixed in #655 and #660, these two are not dead code: they sit inside subscribe callbacks that only run when a sidenav is added/removed dynamically or a drawer's align changes at runtime, so static usage never reached them. Switched both to observable.pipe(first()).subscribe(...). Added specs exercising the previously-unreachable paths: a dynamically added/removed sidenav (drives _sidenavs.changes) and a runtime align change (drives onAlignChanged), each asserting detectChanges() does not throw and that the gated callback (_enableTransitions / _validateDrawers) actually runs once the microtask queue empties. Added an ESLint no-restricted-syntax rule banning the <identifier>.call(observable).subscribe(...) shape so the pattern can't silently regress. The selector is scoped tightly enough to avoid flagging unrelated .call() usage elsewhere in the codebase (Object.prototype.toString.call, Array.prototype.slice.call, a plain callback's callback.call(context, ...)), all confirmed clean via a targeted eslint run. Verified the rule fires as an error against a deliberately reintroduced violation. Because the repo now has zero violations of the pattern, eslint-baseline.json needed no change (warnings actually dropped: 1619 -> 1575 root baseline headroom). coverage-floor.json is untouched; measured coverage is above the floor on every metric. Closes #661
There was a problem hiding this comment.
🟡 Changes recommended
The newly added async specs use await Promise.resolve() (and include an outdated comment) where fixture.whenStable() is the more reliable, idiomatic way to wait for NgZone stability, reducing flakiness risk.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes the remaining RxJS 5-style “unbound operator” usages in the experimental sidenav container by switching to RxJS 7+ pipe(...) operator usage, adds regression coverage in the sidenav specs, and introduces an ESLint guard to prevent reintroducing the problematic .call(observable).subscribe(...) pattern.
Changes:
- Replace
first.call(this._ngZone.onMicrotaskEmpty)withthis._ngZone.onMicrotaskEmpty.pipe(first())inMdSidenavContainer. - Add unit specs covering dynamic sidenav add/remove and runtime
alignchanges to exercise the previously-unreached code paths. - Add an ESLint
no-restricted-syntaxrule to error on the unbound-operator call shape.
File summaries
| File | Description |
|---|---|
| src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.ts | Converts two remaining unbound-operator call sites to pipe(first()) to be compatible with RxJS 7+. |
| src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts | Adds tests for dynamic sidenav changes and align changes to cover the fixed paths and prevent regression. |
| eslint.config.mjs | Adds a restricted-syntax selector to block reintroduction of the unbound-operator .call(...).subscribe(...) pattern. |
Review details
Suppressed comments (3)
src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts:260
await Promise.resolve()does not guarantee the Angular fixture is stable (it only schedules one microtask). Useawait fixture.whenStable()to ensureNgZone.onMicrotaskEmptyhas a chance to emit and to reduce test flakiness.
await Promise.resolve();
src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts:267
- Same as above: prefer
await fixture.whenStable()overawait Promise.resolve()when the intent is to wait for zone microtasks to flush /onMicrotaskEmptyto run.
await Promise.resolve();
src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts:285
- The comment references the pre-fix
first(this._ngZone.onMicrotaskEmpty)form, but the implementation is nowonMicrotaskEmpty.pipe(first()); also, usefixture.whenStable()to reliably wait for the microtask-empty callback rather than a single resolved Promise.
// The container waits for the microtask queue to be empty (via
// first(this._ngZone.onMicrotaskEmpty)) before re-validating, since both
// drawers may be swapping sides at the same time.
await Promise.resolve();
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Address PR review feedback: await Promise.resolve() only flushes a single microtask and isn't a reliable proxy for NgZone/Angular stability, which could make these specs flaky. Switch to fixture.whenStable() in all four call sites (dynamic add, dynamic remove x2, align change), and update the stale in-code comment that still referenced the pre-fix first(this._ngZone.onMicrotaskEmpty) form to describe the actual onMicrotaskEmpty.pipe(first()) shape.
- Rebase branch tip onto latest #675 (gh-582) so this PR's diff/baseline reflect only #586's type-safety changes, not #675's still-open autofix set. #675 is CLEAN/MERGEABLE and independently reviewed; stacking is the documented convention for this repo's slice-based PRs. - Retarget PR base to gh-582-apply-safe-eslint-autofixes-and-lower-the-baseline so the GitHub diff matches (rebase alone doesn't move a PR's base). - Drop the #674 datepicker outside-click fix and #677 sidenav RxJS fix commits from this branch entirely (dropped during rebase) -- both are unrelated bugfixes already merged to master via their own PRs; they were only present here as inherited ancestry from an earlier base choice, not something this PR should carry or take credit for. - pagination.component.ts: options.value widened to 'string | number' -- the template already supports numeric option values via attribute binding; the prior 'any[]' allowed this and the fix's 'string' literal would have narrowed a supported case. - date-time-display.pipe.ts: transform() parameter widened to include 'undefined' explicitly -- the implementation's own guard branch handles undefined and the pipe previously accepted it; the stricter signature would have been a source-compatibility break for existing callers. - types.ts: HistoryNodeType.queryParams value type widened to include readonly arrays and null, matching Angular Router's actual accepted queryParams value shapes (repeated params, param removal) instead of the narrower 'string | number | boolean' which would reject valid existing usage.
Description
Fixes the last two remaining RxJS 5 "unbound operator" call sites in the library, both in
MdSidenavContainer(src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.ts):_sidenavs.changesemits (a sidenav is added/removed dynamically)onAlignChangedemits (a drawer'salignchanges at runtime)Both used the form
first.call(this._ngZone.onMicrotaskEmpty).subscribe(...). Under RxJS 7 (this repo declaresrxjs >=7.5.0), an operator imported fromrxjs/operatorsis a factory that returns anOperatorFunction, so calling it with.call(observable)returns a function, not anObservable— the chained.subscribe(...)then throwsTypeError: ...subscribe is not a functionat runtime.Unlike the three sites already fixed in #655 and #660, these two are not dead code: they sit inside
subscribecallbacks that only run when a sidenav is added/removed dynamically or a drawer'salignchanges at runtime, so static sidenav usage never reached them — which is also why #660 covered this file to ~85% without tripping either one.Converted both to
observable.pipe(first()).subscribe(...).Also adds an ESLint
no-restricted-syntaxrule banning the<identifier>.call(observable).subscribe(...)shape, so this defect class can't silently regress. The selector is scoped tightly enough that it doesn't flag unrelated.call()usage elsewhere in the codebase (Object.prototype.toString.call,Array.prototype.slice.call, a plain callback'scallback.call(context, ...args)), all confirmed clean via a targetedeslintrun. Verified the rule fires as anerroragainst a deliberately reintroduced violation, then removed the scratch fixture.Because the repo now has zero violations of the pattern,
eslint-baseline.jsondid not need to move (warnings actually measured lower: 1575 vs. the 1619 root baseline ceiling).coverage-floor.jsonis untouched; measured coverage is above the floor on every metric.Motivation and Context
Closes #661
Type of Change (Select One and Apply Label)
bugfixlabelenhancementlabelbreakinglabelmaintenancelabelHow to Test
npm ci && npm ci --prefix test-appnpm run format:check— expect passnpm run lint— expect pass (root warnings 1575 ≤ baseline 1619, 0 errors)cd test-app && npm run lint— expect pass (4 warnings ≤ baseline 4, 0 errors)cd test-app && npm test— expect all specs green, including the three new specs insidenav.spec.tscovering: a dynamically added sidenav (enables transitions once the microtask queue empties), a dynamically removed sidenav, and a runtimealignchange (re-validates drawers without throwing)npm run coverage:check— expect pass,coverage-floor.jsonunchangedcd test-app && npm run build— expect a clean Angular buildExpected result: All commands above exit 0. Before this fix, adding/removing a sidenav dynamically or changing a drawer's
alignat runtime under RxJS 7 would throwTypeError: ...subscribe is not a functionfrom insideMdSidenavContainer; after the fix these paths run without throwing.Screenshots (if appropriate)
N/A — backend/logic changes only (no visual changes).
Checklist
gh-<number>-<slug>)format:checkpasses (npm run format:check)lintpasses (npm run lint)buildpasses (cd test-app && npm run build)cd test-app && npm test)