From 095446f3b78451c73ab63023e07b8712d5a91fe4 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 28 Aug 2026 09:34:04 +0200 Subject: [PATCH 1/2] fix: check for a new view version on a view display's "refresh now" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "refresh now" only marked the view's query result as outdated, so a view whose definition had just been superseded kept rendering as the old version. The definition was never re-checked on either path: the memo in View.get is only re-resolved once a minute in the background, and a display built from get-view-displays holds an exact version that is never re-checked client-side at all. View.refreshLatestVersion goes back to the API instead: it drops every memoized resolution leading to the shown version — not just the one keyed by it, since a built-in view is looked up by a hard-coded id — along with the lookups behind them, and re-resolves synchronously. When a newer version is found, the menu escalates from the in-place rebuild to a structure refresh plus a page re-render, the route the page-level "refresh now" takes: the version in use comes from the page's structure, and a new version can change the query, columns, actions and width, which is more than the piece on screen can be patched into. An unchanged view takes the in-place path as before. Closes #654 Co-Authored-By: Claude Opus 5 (1M context) --- .../nanodash/QueryApiAccess.java | 12 +++ .../com/knowledgepixels/nanodash/View.java | 68 ++++++++++++++- .../component/menu/ViewDisplayMenu.java | 24 ++++++ .../knowledgepixels/nanodash/ViewTest.java | 83 +++++++++++++++++++ src/test/resources/np-header-view-v0.trig | 35 ++++++++ src/test/resources/np-header-view-v1.trig | 35 ++++++++ src/test/resources/np-header-view-v2.trig | 35 ++++++++ 7 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 src/test/resources/np-header-view-v0.trig create mode 100644 src/test/resources/np-header-view-v1.trig create mode 100644 src/test/resources/np-header-view-v2.trig diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index c51b161c4..3ae810f52 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -403,6 +403,18 @@ public static String getLatestVersionId(String nanopubId) { return cached != null ? cached.getRight() : nanopubId; } + /** + * Drops the memoized latest-version lookup for a nanopub, so that the next + * {@link #getLatestVersionId(String)} goes back to the query API instead of answering + * from a memo that can be up to a minute old. For the places where the user explicitly + * asks for current data, such as {@link View#refreshLatestVersion(String)}. + * + * @param nanopubId The ID of the nanopublication. + */ + public static void forgetLatestVersion(String nanopubId) { + latestVersionMap.remove(nanopubId); + } + /** * Checks whether the given nanopublication has been loaded by the query services, * with a single cheap indexed lookup. A negative answer only means the instance that diff --git a/src/main/java/com/knowledgepixels/nanodash/View.java b/src/main/java/com/knowledgepixels/nanodash/View.java index 87261d2fa..612dae429 100644 --- a/src/main/java/com/knowledgepixels/nanodash/View.java +++ b/src/main/java/com/knowledgepixels/nanodash/View.java @@ -200,7 +200,7 @@ public static View get(String id) { * @return the View object */ public static View get(String id, boolean resolveLatest) { - String npId = id.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1"); + String npId = toNanopubId(id); if (!resolveLatest) { View exact = getExactVersion(id, npId); if (exact == null || exact.getGoverningSpace() == null || exact.getViewKindIri() == null) { @@ -223,6 +223,70 @@ public static View get(String id, boolean resolveLatest) { return resolved; } + /** + * Re-resolves the latest version of a view, going back to the query API instead of + * trusting what is memoized. This is what lets a view display's "refresh now" bring the + * view up to date and not just its results (issue #654): a memoized resolution + * is only re-checked once a minute in the background, and a display whose view was + * resolved server-side by the {@code get-view-displays} query carries an exact version + * that is never re-checked at all, so a newly published version of the view would + * otherwise not show up until the page's structure happened to be refreshed. + *

+ * Every memoized resolution leading to the given version is dropped along with the + * lookups behind it, so that pages reaching this view by another id — a built-in view is + * looked up by the id hard-coded for it, not by the version that id resolves to — + * re-resolve it on their next render too. + * + * @param id the id of the view version currently shown + * @return the view's current latest version, which is the given one when there is no + * newer version or the lookup fails, or null if the view cannot be loaded at all + */ + public static View refreshLatestVersion(String id) { + // The ids whose lookups are to be forgotten: the given one, plus every memo key + // that leads to it. + Set staleIds = new HashSet<>(); + staleIds.add(id); + for (Map.Entry> memo : latestResolvedViews.asMap().entrySet()) { + View memoized = memo.getValue().getRight(); + if (memo.getKey().equals(id) || (memoized != null && id.equals(memoized.getId()))) { + latestResolvedViews.invalidate(memo.getKey()); + staleIds.add(memo.getKey()); + } + } + for (String staleId : staleIds) forgetLatestVersionLookup(staleId); + View resolved = resolveLatestVersion(id, toNanopubId(id)); + if (resolved != null) { + latestResolvedViews.put(id, Pair.of(System.currentTimeMillis(), resolved)); + } + return resolved; + } + + /** + * Marks the version lookup behind a view id as outdated, so that the next resolution + * asks the API instead of answering from what it holds: the governed-version query for + * a view that floats within its space, the supersedes-chain lookup (its memo and its + * cached response both) for one that does not. + */ + private static void forgetLatestVersionLookup(String viewId) { + String npId = toNanopubId(viewId); + View pinned = getExactVersion(viewId, npId); + if (pinned != null && pinned.getGoverningSpace() != null && pinned.getViewKindIri() != null) { + ApiCache.clearCache(GovernedVersions.getQueryRef( + pinned.getViewKindIri().stringValue(), pinned.getGoverningSpace().stringValue()), 0); + } else { + QueryApiAccess.forgetLatestVersion(npId); + ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_LATEST_VERSION_OF_NP, "np", npId), 0); + } + } + + /** + * The id of the nanopub a view id belongs to: the view id up to and including its + * artifact code. An id that is already a nanopub id is returned unchanged. + */ + private static String toNanopubId(String viewId) { + return viewId.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1"); + } + /** * Resolves a view id to the latest version of its view definition, falling * back to the exact given version if the lookup fails or doesn't yield a @@ -276,7 +340,7 @@ private static View resolveGovernedVersion(View pinned) { String latestId = GovernedVersions.getLatestVersionIriSync( pinned.getViewKindIri().stringValue(), pinned.getGoverningSpace().stringValue()); if (latestId != null && !latestId.equals(pinned.getId())) { - String latestNpId = latestId.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1"); + String latestNpId = toNanopubId(latestId); View resolved = getExactVersion(latestId, latestNpId); if (resolved != null) return resolved; } diff --git a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java index 1d54331e0..67eda5fef 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java @@ -7,6 +7,7 @@ import com.knowledgepixels.nanodash.NavigationContext; import com.knowledgepixels.nanodash.QueryResult; import com.knowledgepixels.nanodash.Utils; +import com.knowledgepixels.nanodash.View; import com.knowledgepixels.nanodash.ViewDisplay; import com.knowledgepixels.nanodash.component.GuidedChoiceItem; import com.knowledgepixels.nanodash.component.RefreshingResultPanel; @@ -198,6 +199,10 @@ protected void onConfigure() { addToOwnLink.setVisible(showAddToOwn); addEntry("addToOwn", addToOwnLink); + // The version of the view definition this display is showing. A newer one can have + // been published since the page was built, which "refresh now" checks for below. + final String shownViewId = viewDisplay.getView() == null ? null : viewDisplay.getView().getId(); + // Refreshes this one view where it stands. Re-rendering the whole page would work too, // but it takes the reader back to the top of it, away from the view they were looking // at — and re-runs everything else on the page for a refresh they asked of one view. @@ -205,6 +210,25 @@ protected void onConfigure() { @Override public void onClick(Optional target) { ApiCache.clearCache(queryRef, 0); + // Bringing a view up to date is not only a matter of re-running its query: + // the view definition itself can have been superseded since this page was + // built, and neither the memoized resolution nor the version the page's + // structure resolved to would notice on their own (issue #654). + View latestView = shownViewId == null ? null : View.refreshLatestVersion(shownViewId); + if (latestView != null && !shownViewId.equals(latestView.getId())) { + // A new version can change everything the display is made of — its query, + // its columns, its actions, its width — which is more than the piece on + // screen can be patched into. The version in use comes from the page's + // structure (the get-view-displays query resolves it server-side), so the + // structure is what has to be asked again: the same route the page-level + // "refresh now" takes, with the current structure kept on screen under a + // spinner until the refreshed one lands. + AbstractResourceWithProfile r = pageResourceId.isEmpty() + ? null : AbstractResourceWithProfile.get(pageResourceId); + if (r != null) r.forceRefresh(0); + setResponsePage(getPage().getClass(), getPage().getPageParameters()); + return; + } QueryResult view = findParent(QueryResult.class); // A view is not always what stands in the page: while it waits for its first // results it is inside Wicket's lazy-loading panel, and while it is being diff --git a/src/test/java/com/knowledgepixels/nanodash/ViewTest.java b/src/test/java/com/knowledgepixels/nanodash/ViewTest.java index efd9fd366..aa0c661c0 100644 --- a/src/test/java/com/knowledgepixels/nanodash/ViewTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/ViewTest.java @@ -1,13 +1,39 @@ package com.knowledgepixels.nanodash; +import org.apache.commons.lang3.tuple.Pair; +import org.eclipse.rdf4j.rio.RDFFormat; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.nanopub.MalformedNanopubException; +import org.nanopub.Nanopub; +import org.nanopub.NanopubImpl; +import java.io.File; +import java.io.IOException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockStatic; class ViewTest { + // Three versions of the same header view: the original a page might reference by a + // hard-coded id, the version that superseded it, and the one published after that. + private static final String NP_V0 = "https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV0"; + private static final String NP_V1 = "https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV1"; + private static final String NP_V2 = "https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV2"; + private static final String VIEW_V0 = NP_V0 + "/view"; + private static final String VIEW_V1 = NP_V1 + "/view"; + private static final String VIEW_V2 = NP_V2 + "/view"; + + private static Nanopub load(String fileName) throws MalformedNanopubException, IOException { + return new NanopubImpl(new File("src/test/resources/" + fileName), RDFFormat.TRIG); + } + @Test void parseMappingLiteralSplitsOnWhitespace() { // single mapping @@ -31,4 +57,61 @@ void parseMappingLiteralHandlesVoidAndEmpty() { assertEquals(List.of("a:foo"), View.parseMappingLiteral("a:foo void")); } + /** + * The point of issue #654: a view display shows the version its page resolved to, and + * that version can have been superseded since. Asking for the view to be refreshed has + * to go back to the API, even where a resolution was memoized moments ago. + */ + @Test + void refreshLatestVersionPicksUpASupersedingVersion() throws Exception { + Nanopub v1 = load("np-header-view-v1.trig"); + Nanopub v2 = load("np-header-view-v2.trig"); + try (MockedStatic utils = mockStatic(Utils.class); + MockedStatic api = mockStatic(QueryApiAccess.class); + MockedStatic cache = mockStatic(ApiCache.class)) { + utils.when(() -> Utils.getAsNanopub(NP_V1)).thenReturn(v1); + utils.when(() -> Utils.getAsNanopub(NP_V2)).thenReturn(v2); + api.when(() -> QueryApiAccess.getLatestVersionId(NP_V1)).thenReturn(NP_V2); + + View shown = View.get(VIEW_V1, false); + assertEquals("First version", shown.getTitle()); + + View refreshed = View.refreshLatestVersion(VIEW_V1); + + assertEquals(VIEW_V2, refreshed.getId()); + assertEquals("Second version", refreshed.getTitle()); + } + } + + /** + * A page can reach the same view by another id — a built-in view is looked up by the id + * hard-coded for it, which the memo maps to whatever that id resolves to. Refreshing the + * shown version has to drop those memos too, or the next render of such a page would put + * the superseded version back on screen. + */ + @Test + void refreshLatestVersionDropsMemosLeadingToTheRefreshedVersion() throws Exception { + Nanopub v0 = load("np-header-view-v0.trig"); + Nanopub v1 = load("np-header-view-v1.trig"); + Nanopub v2 = load("np-header-view-v2.trig"); + try (MockedStatic utils = mockStatic(Utils.class); + MockedStatic api = mockStatic(QueryApiAccess.class); + MockedStatic cache = mockStatic(ApiCache.class)) { + utils.when(() -> Utils.getAsNanopub(NP_V0)).thenReturn(v0); + utils.when(() -> Utils.getAsNanopub(NP_V1)).thenReturn(v1); + utils.when(() -> Utils.getAsNanopub(NP_V2)).thenReturn(v2); + api.when(() -> QueryApiAccess.getLatestVersionId(NP_V1)).thenReturn(NP_V2); + + // The hard-coded id V0 was resolved to V1 a moment ago and memoized as such. + Map> memo = new HashMap<>(); + memo.put(VIEW_V0, Pair.of(System.currentTimeMillis(), View.get(VIEW_V1, false))); + View.importResolvedViews(memo, Long.MAX_VALUE); + assertTrue(View.isCached(VIEW_V0)); + + View.refreshLatestVersion(VIEW_V1); + + assertFalse(View.isCached(VIEW_V0)); + } + } + } diff --git a/src/test/resources/np-header-view-v0.trig b/src/test/resources/np-header-view-v0.trig new file mode 100644 index 000000000..fc6e3cc16 --- /dev/null +++ b/src/test/resources/np-header-view-v0.trig @@ -0,0 +1,35 @@ +@prefix this: . +@prefix sub: . +@prefix np: . +@prefix gen: . +@prefix dct: . +@prefix npx: . +@prefix xsd: . +@prefix rdfs: . +@prefix orcid: . +@prefix prov: . + +sub:Head { + this: a np:Nanopublication; + np:hasAssertion sub:assertion; + np:hasProvenance sub:provenance; + np:hasPublicationInfo sub:pubinfo . +} + +# The original version of the view, the id a page with a hard-coded view reference looks +# it up by; superseded by np-header-view-v1.trig. +sub:assertion { + sub:view a gen:ResourceView, gen:HeaderView; + rdfs:label "header-view"; + dct:title "Original version" . +} + +sub:provenance { + sub:assertion prov:wasAttributedTo orcid:0000-0002-1267-0234 . +} + +sub:pubinfo { + this: dct:created "2026-08-28T08:00:00.000Z"^^xsd:dateTime; + dct:creator orcid:0000-0002-1267-0234; + npx:embeds sub:view . +} diff --git a/src/test/resources/np-header-view-v1.trig b/src/test/resources/np-header-view-v1.trig new file mode 100644 index 000000000..9fdce5231 --- /dev/null +++ b/src/test/resources/np-header-view-v1.trig @@ -0,0 +1,35 @@ +@prefix this: . +@prefix sub: . +@prefix np: . +@prefix gen: . +@prefix dct: . +@prefix npx: . +@prefix xsd: . +@prefix rdfs: . +@prefix orcid: . +@prefix prov: . + +sub:Head { + this: a np:Nanopublication; + np:hasAssertion sub:assertion; + np:hasProvenance sub:provenance; + np:hasPublicationInfo sub:pubinfo . +} + +# A header view, i.e. the one display type that carries no query (issue #572), so that +# loading it does not pull in a query nanopub. +sub:assertion { + sub:view a gen:ResourceView, gen:HeaderView; + rdfs:label "header-view"; + dct:title "First version" . +} + +sub:provenance { + sub:assertion prov:wasAttributedTo orcid:0000-0002-1267-0234 . +} + +sub:pubinfo { + this: dct:created "2026-08-28T09:00:00.000Z"^^xsd:dateTime; + dct:creator orcid:0000-0002-1267-0234; + npx:embeds sub:view . +} diff --git a/src/test/resources/np-header-view-v2.trig b/src/test/resources/np-header-view-v2.trig new file mode 100644 index 000000000..26b39c05f --- /dev/null +++ b/src/test/resources/np-header-view-v2.trig @@ -0,0 +1,35 @@ +@prefix this: . +@prefix sub: . +@prefix np: . +@prefix gen: . +@prefix dct: . +@prefix npx: . +@prefix xsd: . +@prefix rdfs: . +@prefix orcid: . +@prefix prov: . + +sub:Head { + this: a np:Nanopublication; + np:hasAssertion sub:assertion; + np:hasProvenance sub:provenance; + np:hasPublicationInfo sub:pubinfo . +} + +# The superseding version of np-header-view-v1.trig. +sub:assertion { + sub:view a gen:ResourceView, gen:HeaderView; + rdfs:label "header-view"; + dct:title "Second version" . +} + +sub:provenance { + sub:assertion prov:wasAttributedTo orcid:0000-0002-1267-0234 . +} + +sub:pubinfo { + this: dct:created "2026-08-28T10:00:00.000Z"^^xsd:dateTime; + dct:creator orcid:0000-0002-1267-0234; + npx:embeds sub:view; + npx:supersedes . +} From 66e35d9048088b0dc2da8e2176e10e867e8fa394 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 28 Aug 2026 10:12:52 +0200 Subject: [PATCH 2/2] fix: stop a query-form view's "refresh now" from re-rendering the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A query-form view renders a QueryFormPanel, which is not a QueryResult, so findParent(QueryResult.class) came back null, nothing could be rebuilt, and the handler fell into its "no Ajax, nothing to rebuild" fallback: a full page re-render. That is the one display type this happened to — every other kind builds a QueryResult that can be swapped in place — and it is what made these views flicker on every refresh. There is nothing on screen to bring up to date for them: the form collects parameters and the results live on the page it submits to. The query has been marked outdated for that next submit and the view definition has been re-checked, so the refresh is done; repainting the page on top of that changes nothing visible. Measured on a live space page with two query-form views and two regular ones: before, the query-form views navigated to a fresh page render on the click; after, no navigation, while the regular views keep refreshing in place as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../nanodash/component/menu/ViewDisplayMenu.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java index 67eda5fef..f2dff8245 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java @@ -230,6 +230,16 @@ public void onClick(Optional target) { return; } QueryResult view = findParent(QueryResult.class); + if (view == null && target.isPresent()) { + // Not every view display puts results in the page. A query-form view + // shows a form, and the results it leads to live on the page it submits + // to, so there is nothing here to bring up to date: the query has just + // been marked outdated for that next submit, and the view definition has + // been re-checked above. Re-rendering the page on top of that would + // repaint everything for no visible change — which is what made these + // views, alone among the display types, flicker on every refresh. + return; + } // A view is not always what stands in the page: while it waits for its first // results it is inside Wicket's lazy-loading panel, and while it is being // brought up to date inside a RefreshingResultPanel. Either way the wrapper