diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java
index c51b161c..3ae810f5 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 87261d2f..612dae42 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 1d54331e..f2dff824 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,7 +210,36 @@ 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);
+ 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
diff --git a/src/test/java/com/knowledgepixels/nanodash/ViewTest.java b/src/test/java/com/knowledgepixels/nanodash/ViewTest.java
index efd9fd36..aa0c661c 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 00000000..fc6e3cc1
--- /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 00000000..9fdce523
--- /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 00000000..26b39c05
--- /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 .
+}