MemberJunction / MemberJunction/MJ
Explorer deep-link can brick on the loading screen: TabService loses tab requests fired before subscription
- Dominant language
- TSQL
- Stars
- 29
- Forks
- 6
- Avg merge
- 2d 1h
- Merged PRs (30d)
- 323
Description
# Explorer deep-link can brick on the loading screen: TabService loses tab requests fired before subscription
## Symptom
Loading a deep-link URL (e.g. `/app/knowledge-hub/Tags`) hangs on the Explorer loading animation forever. All network calls return 200, the console shows zero errors and zero warnings, and the app never recovers. Reproduced 3 out of 3 cold loads in an automated browser session with cached MSAL auth and a warm metadata cache. The same build works fine in a browser whose login path is slower.
This is a second, independent cause of the "Explorer hangs on the loading screen" symptom that PR #3336 addresses. That PR fixes the resource-level cause (a resource that mounts and then throws or hangs). This issue is about a failure that happens **before any resource component exists**, so the guard and watchdog added in #3336 never arm.
## Root cause chain
1. **Deep-link routes render an unguarded placeholder.** Routes like `app/:appName/:navItemName` map to `SingleRecordComponent` (`packages/Angular/Explorer/explorer-core/src/app-routing.module.ts`, ~L697) with no inputs bound. Its template unconditionally renders `` with an empty entity name. The form host treats empty as "keep waiting", so it shows the `mj-loading` spinner (rotating messages like "Loading workspace...") with no error and no timeout.
2. **The real content depends on a message that can be lost.** `ResourceResolver.resolve()` translates the URL into `TabService.OpenTab(...)`, which pushes into `tabRequest$`, a **plain RxJS `Subject` with no replay** (`packages/Angular/Explorer/base-application/src/lib/tab.service.ts:19`). If the resolver fires before the tab-container subscribes, the request is silently dropped. No queue, no retry, no log.
3. **Timing decides who wins.** With cached auth and warm metadata (`Loaded 406 entities from local cache`, pre-validation current), the resolver resolves fast enough to fire into the void. Slower sessions (interactive auth, cold cache) have the subscriber ready first, which is why this is intermittent and machine-specific, and why it will get *more* common as caching improves.
4. **No safety net exists on this path.** `SingleRecordComponent` extends `BaseAngularComponent`, not `BaseResourceComponent`, so the load-complete watchdog from #3336 never starts. The shell separately finishes its own loading and considers itself done. Result: permanent spinner, healthy network tab, silent console.
Note: the resolver also has several other silent `return` paths (URL debounce, suppress flag, app access check, app-not-found). Any of them strands the same unguarded placeholder, so the fix should cover the class of failure, not just the race.
## Proof
With the app bricked in this state, manually re-firing the identical lost request via the browser console immediately un-bricks it:
```js
const shell = ng.getComponent(document.querySelector('mj-shell'));
const app = shell.appManager.GetAppByPath('knowledge-hub');
const items = await app.GetNavItems();
const tags = items.find(i => i.Label === 'Tags');
shell.tabService.OpenTab({ ApplicationId: app.ID, Title: tags.Label, Configuration: { resourceType: tags.ResourceType, driverClass: tags.DriverClass, appName: 'knowledge-hub', appId: app.ID, navItemName: tags.Label }, IsPinned: false });
```
The `TagsResourceComponent` is created, reaches `LoadComplete: true` through the guarded lifecycle from #3336, and the full dashboard renders. Same request, one subscriber present instead of zero.
## Suggested fixes
1. **Make `tabRequest$` lossless.** `ReplaySubject` (small buffer) or queue-until-first-subscriber, so a request fired before the container is listening is delivered instead of dropped. Small, targeted, unit-testable.
2. **Give the route placeholder a fail-open.** `SingleRecordComponent` / `mj-entity-form-host` should never wait forever on an empty `EntityName`. Treat it as an error state, or add the same watchdog pattern #3336 added at the resource layer.
3. **Surface the resolver's silent returns.** Each silent `return` in `ResourceResolver.resolve()` leaves the user on a spinner with no explanation. These should land on a designed error state (see below) or at minimum log loudly.
## Related UX follow-up (separate work)
While capturing this, we also confirmed that resource load *failures* currently render as normal empty states (e.g. with the API fully down, pages show "No content sources yet" with onboarding CTAs and success iconography, because dashboards do not check `RunView.Success`). Design direction agreed: an error variant of `mj-empty-state` with a Retry button wired to the now-guarded `Refresh()`. That will be tracked separately; this issue is the transport-layer bug.
## Environment
- Branch: `next` merge state as of `fix/base-dashboard-guarantee-loadcomplete` (PR #3336 head)
- Repro: automated Playwright session, cached MSAL profile, warm IndexedDB metadata cache, MJAPI on localhost:4000
- Screenshots of the bricked state and the recovery available (from the investigation session)
Contributor guide
Assessment
This issue has not been assessed yet.