Skip to content

Fix infinite route-loader spinner from per-navigation root cache inva… - #5936

Closed
naomigassler wants to merge 1 commit into
DSpace:mainfrom
naomigassler:fix/root-cache-navigation-deadlock
Closed

Fix infinite route-loader spinner from per-navigation root cache inva…#5936
naomigassler wants to merge 1 commit into
DSpace:mainfrom
naomigassler:fix/root-cache-navigation-deadlock

Conversation

@naomigassler

@naomigassler naomigassler commented Jul 8, 2026

Copy link
Copy Markdown

PR: Fix infinite route-loader spinner on navigation (stale root-endpoint cache deadlock)

Target branch: DSpace/dspace-angular:main
Relates to: #3584, #3697
Fixes #5855

Description

BrowserInitService invalidates the root API endpoint cache on every
NavigationStart. On a subsequent request, HALEndpointService.getEndpointMapAt
(hit on essentially every request via getEndpoint()) discards the now-stale
root /server/api entry and triggers a re-fetch that can deadlock — the
route resolver never completes, NavigationEnd never fires, and the
ds-base-root route-loader spinner hangs indefinitely on a frozen store.

The root endpoint map is effectively static between navigations, so
re-invalidating it on each NavigationStart is unnecessary. Backend-down
detection still happens at init and through normal request-failure handling.

Root cause

  • BrowserInitServiceinvalidateRootCache() on every NavigationStart.
  • Marks the root /server/api endpoint cache stale.
  • Next getEndpoint()getEndpointMapAt discards the stale root → re-fetch
    deadlocks → resolver never resolves → NavigationEnd never fires → spinner
    hangs forever.
  • Higher REST latency makes it fire on nearly every revisit (why it looks
    intermittent).

Steps to reproduce

  1. Open any listing (collection items, MyDSpace, Browse, Search — even
    Communities & Collections).
  2. Navigate listing → listing → listing.
  3. The UI hangs on the route-loader spinner; a full page reload clears it.

Reproducible on a small instance (~7k items) — not load- or scale-dependent.
Higher REST/proxy latency increases the frequency.

Fix

Remove the per-NavigationStart invalidateRootCache() call in
browser-init.service.ts; keep the one-time invalidation at init. No behavior
change to backend-down detection.

How to test

  1. Before the fix: reproduce the hang via the steps above.
  2. After the fix: the same navigation sequence no longer hangs; the spinner
    always resolves.
  3. Backend-down handling still works: stop the REST backend and confirm the
    app still detects/handles it (init-time + request-failure paths).

Tests

Added a spec asserting the root endpoint cache is invalidated once at init
and not on subsequent NavigationStart events.

Checklist notes

  • One-file logic fix; no new dependencies; no user-facing strings (i18n N/A).
  • Passes yarn lint and yarn check-circ-deps.
  • TypeDoc added/updated on any modified public method.

…lidation

BrowserInitService invalidated the root /server/api endpoint cache on every
NavigationStart. That marks the root request stale (RootDataService
.invalidateRootCache -> RequestService.setStaleByHref). HALEndpointService
.getEndpointMapAt, used by getEndpoint() for every data request, discards a
stale root via filter(rd => !rd.isStale), and its re-fetch can lose the race
with the next invalidation, so getEndpoint() never resolves, the route
resolver never completes, and the route loader spins forever.

The root endpoint map is static between navigations, so invalidating it on
every navigation is unnecessary. Remove the per-NavigationStart invalidation
and keep the one-time invalidation at app init; backend availability is still
established there and surfaces through normal request failures.

Fixes DSpace#3584, DSpace#3697.
@lgeggleston lgeggleston added bug performance / caching Related to performance, caching or embedded objects 1 APPROVAL pull request only requires a single approval to merge port to dspace-8_x This PR needs to be ported to `dspace-8_x` branch for next bug-fix release port to dspace-9_x This PR needs to be ported to `dspace-9_x` branch for next bug-fix release port to dspace-10_x This PR needs to be ported to `dspace-10_x` branch for next bug-fix release labels Jul 9, 2026
@lgeggleston lgeggleston moved this to 🙋 Needs Reviewers Assigned in DSpace 11.0 Release Jul 9, 2026
@tinsch

tinsch commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

I was able to reproduce the issue with the DSpace sample data. The steps to reproduce it locally were:

  1. login as admin
  2. start a simple search on the whole repo (hit search on front page)
  3. click on DCAT Journal Publications
  4. go to "MyDSpace" listing
  5. observe infinite loading on the list

I tested this PR, and the issue did not occur anymore 🎉. But then again, I tried to reproduce the original issue on main, just to double check - and could not successfully reproduce it anymore. So I would suggest:

  • more people should try to reproduce the original issue and confirm that this PR fixes it
  • a frontend dev could review the code and based on that we can decide to merge this PR, even if the original bug is not reproducible every time

Thanks for the PR! This is a great contribution in my opinion.

@jlipka

jlipka commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Hey @tinsch and others in here.
Thanks for listing your click sequence—unfortunately, I wasn't able to reproduce the effect using that, but I've included a similar attempt below.

Starting point:
The code in the main branch without any changes from this MR!

I see the infinite route loader appear when I double-click very quickly on a navigation item.
You can also reproduce this in the DSpace Sandbox.

  1. Open https://sandbox.dspace.org
  2. Hover over “All of DSpace” and double-click on “By title”
  3. As a result, you’ll see the loading spinner centered in the middle (it no longer disappears).
  4. If that didn’t work right away, double-click again on, for example, “By author”
    (You should see the effect after a few attempts at most.)

Screencast on the DSpace sandbox page
(on the second attempt the spinner appears)

The issue can apparently be reproduced this way—unless I’m missing something.
I used Firefox running on an Ubuntu/Linux.

If I now apply the fix from the merge request, the error no longer occurs. Therefore, in my opinion, the fix makes perfect sense—especially since the logic in the underlying code apparently isn’t needed at all.


So far, so good.

But now I’m wondering if this really solves the problem. Even if we probably don’t actually need to manually invalidate the /server/api API endpoint on every NavigationStart event, invalidating requests is still a common pattern in DSpace (at this point, I’d just like to loosely refer to the msToLive property in the configuration). Other requests will also become invalid or stale over the course of the application’s lifecycle, and the app shouldn’t freeze up in such cases either.

So, another approach or at least a try could be to swap the order of the following two lines:
Original code in findbyHref in base-data.service.ts

skipWhile((rd: RemoteData<T>) => rd.isStale || (!useCachedVersionIfAvailable && rd.lastUpdated < startTime)),
this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),

We could move the skipWhile operator below the reRequestStaleRemoteData sideffect, as its a regular tap operator wrapped inside another function.

  • Could this be a valid (additional) solution?
  • Does skipWhile still serve its purpose, or has the logic been modified in an unintended way?

No matter what sequence of clicks I used after making this change, I could no longer trigger the state where the loading spinner is displayed continuously.

Long story short:

  • I think the change in this MR makes sense, and I can reproduce it myself using the sequence of clicks I described above
  • At the same time, in my opinion, the fix does not resolve a potential issue in BaseDataService that might be blocking streams unintentionally—because if the skipWhile mechanism is moved, the freezes won’t occur, regardless of whether we use the fix in this merge request or not. However, it hasn't yet been determined whether this would cause problems elsewhere in the app.
  • I’m aware that the calculated Boolean for the LoadingIndicator is retrieved in app.component.ts. We could certainly make adjustments here as well, but this again raises the question for me of whether this might be masking a potential (!—I’m not sure) problem elsewhere.

I’d be very interested in hearing your thoughts on these points, and of course whether you can reproduce the behavior.

@jlipka

jlipka commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

After further investigation, I might have found a better, more logic solution, which leaves my previous idea seeming obsolete.


Original code:

isValid = (entry: RequestEntry): boolean => {
if (hasNoValue(entry)) {
// undefined entries are invalid
return false;
} else {
if (isLoading(entry.state)) {
// entries that are still loading are always valid
return true;
} else {
if (isStale(entry.state)) {
// entries that are stale are always invalid
return false;
} else {
// check whether it should be stale
const timeOutdated = entry.response.timeCompleted + (entry.request.responseMsToLive ?? this.defaultResponseMsToLive);
const now = new Date().getTime();
const isOutDated = now > timeOutdated;
return !isOutDated;
}
}
}
};

My (partial) code changes:

const isValid = (entry: RequestEntry): boolean => {
  if (hasNoValue(entry)) {
    // undefined entries are invalid
    return false;
  } else {
    if (isStale(entry.state)) {
      return false;
    } else if (isLoading(entry.state)) {
      return true;
    } else {
      // check whether it should be stale
      const timeOutdated = entry.response.timeCompleted + entry.request.responseMsToLive;
      const now = new Date().getTime();
      const isOutDated = now > timeOutdated;
      return !isOutDated;
    }
  }
};

This change targets to check isStale first (no matter if the loading state is currently true or false), before to check the loading state, and last to check the TTL.


Another, additional check I added in the shouldDispatchRequest method in the same file (request.service.ts), starting around Line 493:

Original code:

} else {
// if we are, check the request cache
const urlWithoutEmbedParams = getUrlWithoutEmbedParams(request.href);
if (this.hasByHref(urlWithoutEmbedParams) === true) {
return false;
} else {

My (partial) code changes:

    } else {
      // if we are, check the request cache
      const urlWithoutEmbedParams = getUrlWithoutEmbedParams(request.href);
      if (this.hasByHref(urlWithoutEmbedParams) === true) {
        return false;
      } else if (this.hasByHref(urlWithoutEmbedParams, false) === true) {
        // an entry exists but is stale/outdated -> always fetch fresh data,
        // regardless of whether the object is still present in the object cache
        return true;
      } else {

This change ensures that a request entry that exists in the request cache but is stale or expired is always re-fetched from the server—regardless of whether the associated object is still present in the object cache. Previously, in this case, the system would incorrectly fall back on the object cache and suppress the request, even though the data was marked as stale.


As before, the fix in this merge request makes perfect sense. My additional changes could make the app even more robust.

I have to do some more testing about these changes (especially in our customized instance)... but wanted to let you know about my current investigation.

@lgeggleston lgeggleston moved this from 🙋 Needs Reviewers Assigned to 👀 Under Review in DSpace 11.0 Release Aug 14, 2026
@tinsch

tinsch commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Thanks @jlipka for your investigation. I can reproduce the spinner issue on sandbox with your click sequence, but not locally.

I tried my click sequence again to reproduce the issue, and it happened reliably on main and was fixed on the PR branch. However, I looked into the PR code and tested it further: The problem is, we now don't invalidate the backend route anymore on NavigationStart. This means that if the backend is down (simulated locally by turning off the backend container) the frontend won't notice. I can still click around in the UI and get some errors on certain pages, but this will be confusing for end users who don't know what's happening when the UI still looks somewhat fine. It can lead to data loss on the users side, and a lot of questions to the repository owners should the backend be down. Unfortunately I think we cannot use the fix for this reason.

@naomigassler could you look into this please? Maybe there is a way to prevent the problem I described and still fix the spinner issues by tweaking the code a bit.
@jlipka would you be willing to open an additional PR that includes your fix, so we could discuss it separately?

To help with further investigations:
When reproducing the spinner issue on the workspace page, I saw a 400 response from the backend every time the spinner issue occurred.
Screenshot 2026-08-20 at 14 40 24
Screenshot 2026-08-20 at 14 40 50

The route was http://localhost:8080/server/api/discover/search/objects?sort=lastModified,DESC&page=0&size=10&configuration=workspace&embed=thumbnail&embed=item%2Fthumbnail and the errors in the backend indicate that the frontend does not properly send the login cookie on that request:

2026-08-20 12:45:03,898 ERROR unknown 66fe317a-003e-44f0-a1f5-1841e2c99fe7 org.dspace.discovery.utils.DiscoverQueryBuilder @ anonymous::Error in Discovery while setting up date facet range:date facet\colon; org.dspace.discovery.configuration.DiscoverySearchFilterFacet@38816a6c
org.dspace.discovery.SearchServiceException: An anonymous user cannot perform a workspace or workflow search

@naomigassler

naomigassler commented Aug 21, 2026

Copy link
Copy Markdown
Author

Hello @tinsch and @jlipka,

We measured every candidate on a test box against two scenarios: A the
deadlock, B the backend stopped. Full write-up and raw data available if
useful.

variant Scenario A (deadlock) Scenario B (backend down) cost
baseline hangs 3/3 /500 every navigation, 338 ms
this PR fixed 0/3 no /500; inline "Error fetching …" at 353 ms; /mydspace blank, no error none
our published alternative hangs 3/3 = baseline
@jlipka's isValid reorder wedges the browser tab rejected
decoupled liveness probe fixed 0/3 = baseline (/500 every navigation) 1 root request per navigation

Measured on our DSpace 9.3 deployment with added REST latency, not on main. The
/mydspace blank page in row 2 is reachable only with this PR applied — see below.

A correction to our own issue report. The alternative we suggested there —
filter(rd => !rd.isStale || rd.hasCompleted) in getEndpointMapAt  does not
work
. It hangs 3/3 with a frozen-store signature byte-identical to the unfixed
baseline: the stale-but-completed RemoteData never reaches that filter, so the
deadlock is upstream of it. Please disregard it, and apologies to anyone who spent
time on it.

On the objection. "Won't notice the backend is down" isn't quite what we
measured — the outage surfaces on the first navigation at 353 ms, as inline errors
on most routes. But there is no /500, the root stays Success/200 for its full
6 h TTL, and /mydspace renders an empty main area with no error at all. There a
user cannot distinguish an outage from an empty repository, and that is exactly
where the data-loss concern lives, because submissions are on that page.

So the concern was sound even where the mechanism wasn't, and chasing it turned up
a pre-existing bug: <ds-search> renders nothing — no results, no empty state, no
error — when its search-configuration request fails from a cold cache. It backs 6
routes, is reproducible on main today, and is filed as #6111.

But on unmodified main you cannot reach it by stopping the backend, because
ServerCheckGuard redirects to /500 first. This PR's deletion removes that
blanket, so it converts an unreachable blank page into a reachable one on
/mydspace. That is a fair reading of the objection and we would rather state it
than have it found in review. The consequence is a merge order rather than a
rejection: with #6111 in, those 6 routes report the outage honestly and this PR has
no silent hole on search-backed pages. #6111 stands on its own either way.

What the actual decision is. Today's /500 is a categorical guarantee: every
route reports an outage. This PR replaces it with per-route handling — better where
implemented, worse where it isn't. Two coherent positions, and we don't think our
measurements settle which is right:

  1. Per-route errors. This PR + Fix <ds-search> rendering an empty page when the search configuration request fails #6111 + finish the remaining routes. Smallest
    changes, better messages, but inherently whack-a-mole: /community-list still
    sits on "Loading…" indefinitely with the backend down, on its own data path.
  2. Keep a categorical signal. Make liveness an explicit HttpClient probe that
    never enters the ngrx store, keeping this PR's deletion. Scenario B came out
    bit-for-bit identical to baseline, which is what proves the decoupling. Cost:
    one root request per navigation (settle 293–847 ms vs this PR's 162–656 ms;
    baseline pays the same), a new service plus a guard rewrite instead of deleting
    five lines, and a de-duplication TTL we picked rather than derived.

They are compatible. Happy to open the probe separately if there is appetite.

On the isValid reorder, tested as its own variant: the page stops responding
to any script about 9 s in and never recovers — worse than the original deadlock,
which at least leaves the shell responsive. Requests freeze rather than climb, so
it isn't a request storm, and controls through the same harness stay clean, so it
isn't the harness. The accompanying shouldDispatchRequest change also looks
redundant: with isValid corrected, the object-cache fallback calls
hasByUUID(..., true) and runs the same corrected check; the two were identical in
our runs.

Caveat. Our reproduction amplifies the production race with added REST latency,
so we have shown these mechanisms are sufficient to produce the hang, not that
they are the only cause.

@tinsch

tinsch commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@naomigassler thanks for taking a look into it again. However I don't feel this conversation leads to a real fix, and it feels like a conversation with an LLM rather than a conversation between humans. I am sorry if I am wrong here. Could you please disclose it here and on your other PR? (#6111). If the comments above were not made by an LLM, please ignore the text below, and let me know I was wrong.

If the comments and the code are entirely submitted by an LLM, I unfortunately have to step back from reviewing it further.

The reasons are:

  • The fix provided here is not a real fix for the issue. With the PR applied, our application does not indicate anymore that it is in a broken state if the backend is down on a lot of routes and pages. The other PR fixes that only for one particular page. This is not acceptable, and I think that implementing a liveness probe through this LLM conversation could lead to further bugs.
  • I also feel that I am wasting my time, arguing with an LLM when I did not explicitly choose to do so.
  • I feel disrespected when a comment is not written by a person, but every one of my replies is. It just feels disrespectful that the other person did not take the time to think about it and write a short paragraph of text to acknowledge my thoughts.

@jlipka

jlipka commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Hey,
I apologize for the confusion here. We've been hitting a few of these small bugs lately, and in my eagerness to find a quick fix, I might have overlooked some edge cases. I’ll open a separate bug ticket for it when I get a chance. Thanks, @tinsch, for catching this!


Regarding the actual issue and @tinsch’s valid comments:
The question remains for me whether a check to see if the backend is still accessible should be implemented in this way. Perhaps a simple “health” request running in the background at regular intervals could just as effectively highlight the problem of an unreachable backend.

It seems a bit odd to use the user’s navigation/click behavior as the trigger for determining the status of the backend server. After all, in the best-case scenario, the user wouldn’t even be able to perform UI interactions if the server is actually unreachable—in the sense of a proactive check rather than a reactive response to the interaction itself. But that’s just a quick thought.

@tinsch

tinsch commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@jlipka you are right, there might be a better way to to the liveness/health check than on every user navigation. It is just the way how it is implemented now. We could open a separate ticket for this.

@naomigassler

Copy link
Copy Markdown
Author

Hello @tinsch,

i apologise for not having disclosed that my comments were written by an LLM. I am not a java developer but I have been working with programmers for more than 20 years on customising our dspace installation. Now i can do this myself with an LLM. Having spent many days on finding the solution for this bug, I was delighted to give something in return to the dspace project. but without prior experience in filing PRs in GitHub I relied on an LLM for help. I understand if that disqualifies this patch.

I agree with @jlipka that we should have a different way to check if the backend is still alive. But I don't think I can contribute to this issue, I would be a bit out of my depth.

@tinsch

tinsch commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@naomigassler thanks for disclosing it now, and thanks for taking the time to finding a fix with the help of an LLM. Using an LLM does not disqualify the patch, the problem is that the patch introduces other issues. I understand that you were happy to contribute something, and I myself also would have loved to find a fix for this annoying endless spinner issue! So thanks for bringing this up and attempting a fix.

Do you want to have this PR and the other one (#6111) stay open, or can we close them and continue the work on a fix somewhere else?

@naomigassler

Copy link
Copy Markdown
Author

@tinsch I think this PR is the correct fix, we just need to find another way to check that the backend is still responding. #6111 fixes a real bug which is not apparent now but might be in the future. the fix is tiny and obvious so no harm to keep it. But at the moment it doesn't do anything so you might want to just close it.

@tinsch

tinsch commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@naomigassler but how do you think we could proceed here? Since the fix needs adjustments, but you cannot provide them?

@naomigassler

Copy link
Copy Markdown
Author

This comment is a bit long, but it wraps up the entire story. So please bear with me.

We have a reliable way to reproduce the hang on our system. So I looked precisely again (with the help of a coding agent) what is causing the hang. (I should have done this earlier but got too excited with finding a fix for a bug that has plagued my team for so long.)

The timeline below involves two root request cache entries (…b577eb5662f8 and …259dce4bdb8f), reproduced with 2,500 ms injected REST latency and two navigations 800 ms apart.

+340 ms
nav 1: invalidateRootCache marks cached root …b577eb5662f8 stale

+361 ms
guard's findByHref: hasByHref false → object-cache fallback → true → dispatches …259dce4bdb8f. Working correctly

+1147 ms
nav 2: invalidateRootCache marks …259dce4bdb8f, still in flight, stale → ResponsePendingStale

+1209 ms
guard again: hasByHref returns true for that entry → early return, nothing dispatched

+1210 / +2918 ms
skipWhile(rd => rd.isStale) skips ResponsePendingStale, then skips the SuccessStale completion

then nothing, for ~27 s

So there are two defects playing together:

(a) isValid() calls a stale-but-loading entry valid — it tests isLoading before isStale, and isLoading includes ResponsePendingStale, so the stale check is never reached. This is precisely the predicate jlipka's reorder fixes.

(b) skipWhile sits in front of the re-request. findByHref places skipWhile(rd => rd.isStale) immediately before reRequestStaleRemoteData — the operator whose entire job is "if this is stale, re-request it". In 3/3 runs its tap saw only RequestPending and ResponsePending, willReRequest: false; it never saw the stale completion. The culprit is this skipWhile, not anything in HALEndpointService. (The original description of this PR points at HALEndpointService as the culprit. This is now determined to be wrong.)

#5936 removes one trigger of these 2 defects playing out, but it doesn't remove the defects.

@jlipka was closer to the root cause than we were. Their first idea — moving skipWhile below reRequestStaleRemoteData — is defect (b). Their second — the isValid reorder — is defect (a). (But it stops the UI responding entirely on our system in 3/3 runs, independently of whether their first idea was applied or not.)

I tried moving skipWhile below reRequestStaleRemoteData on a clean system based on main @ 8a63671 with our system backend. This removes the hang without fail (0 hangs in 24 rounds across 3 runs, against 3/3 hangs on the unmodified clone). So all credit to @jlipka for the swap they proposed in their 13 August comment.

There is some extra cost: with the move, root requests per navigation go from 9–10 to 26–27 . That is the re-request actually happening — requests that should have been sent and were not — but it is roughly a threefold increase, and it may be worth looking at whether the re-request should be de-duplicated.

For the path forward: this PR should now be closed, up to @jlipka to put in a PR with their reordering idea.

Note: It is a question whether invalidating the root cache on every navigation is the best way to test whether the backend is responsive. I have written a probe for our system which proactively tests the backend at a given interval and gives the user a message when the backend becomes unresponsive even if the user is not navigating the UI. It is 283 lines across 13 files. Removing the invalidation of the root cache then becomes an efficiency gain. @tinsch you said earlier that you worry, understandably, about new bugs being introduced with new code. But, if there is an interest to try out this probe I'll be happy to put it into a PR.

Below is a spec to insert into src/app/core/data/base/base-data.service.spec.ts to test the fix.

// Regression: the operator that re-requests stale data sits behind a skip that
// removes stale data. These two specs drive the real pipeline — note that they
// deliberately do NOT stub reRequestStaleRemoteData, unlike every other spec in
// this file, because the interaction between it and the skipWhile immediately in
// front of it is exactly what is broken.
describe(`findByHref with an entry that goes stale while in flight`, () => {
  beforeEach(() => {
    spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink);
  });

  it(`should re-request the stale RemoteData`, () => {
    testScheduler.run(({ cold }) => {
      // What a caller subscribing after the invalidation sees: the request it
      // is waiting on was marked stale while still in flight, and then
      // completed stale. There is no non-stale value in the stream, so the
      // skipWhile never stops skipping.
      spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b', {
        a: remoteDataMocks.ResponsePendingStale,
        b: remoteDataMocks.SuccessStale,
      }));

      service.findByHref(selfLink, true, true, ...linksToFollow).subscribe();
    });

    // createAndSendGetRequest is called once by the original findByHref, and
    // once more by each re-request. A count of 1 therefore means nothing
    // re-requested the stale data, and the caller waits for ever.
    expect((service as any).createAndSendGetRequest.calls.count()).toBeGreaterThan(1);
  });

  it(`should not emit the stale RemoteData to the caller`, () => {
    testScheduler.run(({ cold, expectObservable }) => {
      spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b', {
        a: remoteDataMocks.ResponsePendingStale,
        b: remoteDataMocks.SuccessStale,
      }));

      // The skip exists to stop stale cached data reaching consumers, and it
      // must keep doing that: re-requesting is not a licence to leak.
      expectObservable(service.findByHref(selfLink, true, true, ...linksToFollow)).toBe('---');
    });
  });
});

A few notes from my coding agent about this:

  • On unmodified main @ 8a63671 the first spec fails with Expected 1 to be greater than 1; with the skipWhile moved it passes, and the other 5,926 specs are unaffected. It needs no backend and no configuration — it is a marble test over a stubbed request service so anyone can check it with a checkout and npm test.
  • Placement. It sits inside the existing describe(findByHref, …) block, immediately after the when useCachedVersionIfAvailable is false block. That's not cosmetic — it inherits that block's spyOn(service as any, 'createAndSendGetRequest'), which is what the first assertion counts. My first attempt put it one level out, and the spec then failed with a TypeError instead of the assertion.
  • No stub on reRequestStaleRemoteData. Every other spec in the file does spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source), which neutralises exactly the operator that's broken. Dropping that stub is the whole point.
  • The marble 'a-b' with two stale values and no non-stale prefix models the second navigation's subscriber from the browser trace: skipWhile is a prefix operator, so once it passes a non-stale value it never skips again. The frozen consumer is the one whose stream starts stale and stays stale.
  • toBeGreaterThan(1) rather than toBe(2). With two stale emissions the fixed code re-requests once per emission, so the exact count is 3, not 2. toBeGreaterThan(1) states the property that matters — "something re-requested it" — without pinning a number that a future dedup change would break. It still fails cleanly on unmodified main: Expected 1 to be greater than 1.
  • The second it passes with and without the fix, deliberately. It documents that the skip still does its legitimate job, so the change can't be accused of leaking stale data to consumers. It's a non-regression assertion, not a discriminating one.

@github-project-automation github-project-automation Bot moved this from 👀 Under Review to ✅ Done in DSpace 11.0 Release Aug 26, 2026
@tinsch tinsch moved this from ✅ Done to ❓ Stalled/On Hold in DSpace 11.0 Release Aug 26, 2026
@tdonohue tdonohue removed the port to dspace-8_x This PR needs to be ported to `dspace-8_x` branch for next bug-fix release label Aug 26, 2026
@tdonohue tdonohue removed port to dspace-9_x This PR needs to be ported to `dspace-9_x` branch for next bug-fix release port to dspace-10_x This PR needs to be ported to `dspace-10_x` branch for next bug-fix release labels Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1 APPROVAL pull request only requires a single approval to merge bug performance / caching Related to performance, caching or embedded objects

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Infinite route-loader spinner: per-navigation invalidateRootCache() deadlocks getEndpointMapAt when the root endpoint goes stale

5 participants