Skip to content

Commit d70ca2a

Browse files
namedgraphclaude
andauthored
Defer a view's whole-result-set queries until they can be seen (#445)
* Defer a view's whole-result-set queries until they can be seen Related-results discovery and the view-container lookup both run over a view's whole result set, and every view sent both on every load. Neither shows anything until asked: the pivot chips sit in a bar the card draws collapsed, and the container only becomes a Create button for an agent who could create there. - ldh:render-view discovers only while the view's pivot bar is open, and caches the focus variable and property labels beside the query, endpoint and results it already keeps - a tb-controls rule in view.xsl runs the block's toggle, then discovers for every pivot bar it left open, from the cache - ldh:ViewContainer is asked only when the view names a class to construct and an agent is signed in, with Write on this document for a forward view Driven on the local LTLOD stack with Playwright (develop vs this branch, SEF served through page.route): anonymous loads send neither query, the controls toggle discovers once with the same pills per view as develop, and the owner still gets all five Create buttons on persons/7242/. That page's store goes quiet at 2.8 s instead of 15.7 s, the homepage at 2.5 s instead of 3.6 s. deferred-queries.spec asserts the deferral against the ui-tests fixtures; it has not been run yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Related-results discovery shows the progress cursor while it runs Discovery now runs only once the pivot bar is opened, and the bar stays hidden until its first pill arrives, so nothing showed that the request was underway. It sets the busy cursor before the request and resets it in the chain's finally, the way the facet, pager, sort and pivot queries already do. Reopening a bar over the same result set sends no request and sets no cursor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 661e2cd commit d70ca2a

2 files changed

Lines changed: 146 additions & 14 deletions

File tree

‎src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/view.xsl‎

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,10 +1368,16 @@ exclude-result-prefixes="#all"
13681368
only on the initial load: the answer is a property of the query, so paging, sorting and faceting
13691369
cannot change it. The slot it fills is the card header's, emitted by ac:BlockActions with the
13701370
rest of the block's actions - a view rendered without a card header (a modal-hosted one) has no
1371-
slot and no @data-for-class to construct from either, so both say the same thing by being absent -->
1372-
<xsl:if test="$initial-load">
1371+
slot and no @data-for-class to construct from either, so both say the same thing by being absent.
1372+
The query spans the whole result set, so it is only asked when its answer could become a button:
1373+
ldh:view-create-insert needs a class to construct, and an agent who can write - to this document as
1374+
well, for a forward view. Only the container's own access, which the query has to find first, is
1375+
left to ldh:view-create-insert -->
1376+
<xsl:variable name="create-slot" select="$container/ancestor::div[contains-token(@class, 'block')][1]/div[contains-token(@class, 'ldh-block-head')]/div[contains-token(@class, 'actions')]/span[contains-token(@class, 'ldh-view-create')]" as="element()?"/>
1377+
<xsl:variable name="create-block" select="$create-slot/ancestor::div[contains-token(@class, 'block')][1]" as="element()?"/>
1378+
<xsl:if test="$initial-load and exists($create-block/@data-for-class) and exists($acl:agent) and (exists($create-block/@data-inverse) or $acl-modes = '&acl;Write')">
13731379
<xsl:call-template name="ldh:ViewContainer">
1374-
<xsl:with-param name="create-slot" select="$container/ancestor::div[contains-token(@class, 'block')][1]/div[contains-token(@class, 'ldh-block-head')]/div[contains-token(@class, 'actions')]/span[contains-token(@class, 'ldh-view-create')]"/>
1380+
<xsl:with-param name="create-slot" select="$create-slot"/>
13751381
<xsl:with-param name="focus-var-name" select="$focus-var-name"/>
13761382
<xsl:with-param name="acl-modes" select="$acl-modes"/>
13771383
<xsl:with-param name="endpoint" select="$endpoint"/>
@@ -1847,16 +1853,49 @@ exclude-result-prefixes="#all"
18471853
'inverse-var-name': 'inverse' || $suffix,
18481854
'property-metadata': $property-metadata
18491855
}"/>
1856+
1857+
<!-- discovery runs only while the pivot bar is open, so someone is waiting on it, and the bar
1858+
stays hidden until its first chip arrives: the cursor is all that says it is underway -->
1859+
<xsl:sequence select="ldh:busy-cursor()"/>
1860+
18501861
<ixsl:promise select="ixsl:http-request($context('request')) =>
18511862
ixsl:then(ldh:rethread-response($context, ?)) =>
18521863
ixsl:then(ldh:handle-response#1) =>
1853-
ixsl:then(ldh:parallax-response#1)"
1864+
ixsl:then(ldh:parallax-response#1) =>
1865+
ixsl:finally(ldh:reset-cursor#0)"
18541866
on-failure="ldh:promise-failure(., 'block-query-failed', ?)"/>
18551867
</xsl:if>
18561868
</xsl:if>
18571869
</xsl:for-each>
18581870
</xsl:template>
1859-
1871+
1872+
<!-- A view under a collapsed pivot bar renders without discovering its chips (ldh:render-view), so opening
1873+
the bar is what discovers them, from the result set the view last rendered. The block's own toggle runs
1874+
first and stays ignorant of views; this rule only reads which bars it left open. A view that has not
1875+
rendered yet has nothing cached to discover from, and discovers in ldh:render-view instead, where the
1876+
bar is then found open. Toggling again does not re-query: ldh:ParallaxNav skips an unchanged result set -->
1877+
<xsl:template match="div[contains-token(@class, 'block')]//button[contains-token(@class, 'tb-controls')]" mode="ixsl:onclick" priority="1">
1878+
<xsl:variable name="block" select="ancestor::div[contains-token(@class, 'block')][1]" as="element()"/>
1879+
1880+
<xsl:next-match/>
1881+
1882+
<xsl:for-each select="$block//div[contains-token(@class, 'ldh-pivot-bar')][not(contains-token(@class, 'is-collapsed'))]/div[contains-token(@class, 'ldh-pivot-pills')]">
1883+
<xsl:variable name="cache" select="ldh:view-cache(.)" as="item()?"/>
1884+
1885+
<xsl:if test="exists($cache) and ixsl:contains($cache, 'results') and ixsl:contains($cache, 'focus-var-name')">
1886+
<xsl:call-template name="ldh:ParallaxNav">
1887+
<xsl:with-param name="empty-results" select="empty(ixsl:get($cache, 'results')/rdf:RDF/*)"/>
1888+
<xsl:with-param name="select-xml" select="ixsl:get($cache, 'select-xml')"/>
1889+
<xsl:with-param name="endpoint" select="ixsl:get($cache, 'endpoint')"/>
1890+
<xsl:with-param name="focus-var-name" select="ixsl:get($cache, 'focus-var-name')"/>
1891+
<xsl:with-param name="property-metadata" select="if (ixsl:contains($cache, 'property-metadata')) then ixsl:get($cache, 'property-metadata') else ()"/>
1892+
<xsl:with-param name="cache" select="$cache"/>
1893+
<xsl:with-param name="properties-container-id" select="@id"/>
1894+
</xsl:call-template>
1895+
</xsl:if>
1896+
</xsl:for-each>
1897+
</xsl:template>
1898+
18601899
<!-- resolves the view's client-side cache entry: block-hosted views are keyed by the host block's @about, modal-hosted views (geo/latest/search/class instances) by the container's @id -->
18611900
<xsl:function name="ldh:view-cache" as="item()">
18621901
<xsl:param name="container" as="element()"/>
@@ -3096,15 +3135,26 @@ exclude-result-prefixes="#all"
30963135
</xsl:call-template>
30973136
</xsl:for-each>
30983137

3099-
<xsl:call-template name="ldh:ParallaxNav">
3100-
<xsl:with-param name="empty-results" select="empty($sorted-results/rdf:RDF/*)"/>
3101-
<xsl:with-param name="select-xml" select="$select-xml"/>
3102-
<xsl:with-param name="endpoint" select="$endpoint"/>
3103-
<xsl:with-param name="focus-var-name" select="$focus-var-name"/>
3104-
<xsl:with-param name="property-metadata" select="$property-metadata"/>
3105-
<xsl:with-param name="cache" select="$cache"/>
3106-
<xsl:with-param name="properties-container-id" select="$container-id || '-parallax-properties'"/>
3107-
</xsl:call-template>
3138+
<!-- what a pivot bar opened later discovers from, beside the select-xml, endpoint and results the
3139+
cache already holds (the tb-controls rule beside ldh:ParallaxNav) -->
3140+
<ixsl:set-property name="focus-var-name" select="$focus-var-name" object="$cache"/>
3141+
<xsl:if test="exists($property-metadata)">
3142+
<ixsl:set-property name="property-metadata" select="$property-metadata" object="$cache"/>
3143+
</xsl:if>
3144+
3145+
<!-- discovery runs over the whole result set and is the costliest query a view sends, so it runs
3146+
only while its chips can be seen: a collapsed pivot bar defers it to the controls toggle -->
3147+
<xsl:if test="($container//div[contains-token(@class, 'ldh-pivot-bar')])[1][not(contains-token(@class, 'is-collapsed'))]">
3148+
<xsl:call-template name="ldh:ParallaxNav">
3149+
<xsl:with-param name="empty-results" select="empty($sorted-results/rdf:RDF/*)"/>
3150+
<xsl:with-param name="select-xml" select="$select-xml"/>
3151+
<xsl:with-param name="endpoint" select="$endpoint"/>
3152+
<xsl:with-param name="focus-var-name" select="$focus-var-name"/>
3153+
<xsl:with-param name="property-metadata" select="$property-metadata"/>
3154+
<xsl:with-param name="cache" select="$cache"/>
3155+
<xsl:with-param name="properties-container-id" select="$container-id || '-parallax-properties'"/>
3156+
</xsl:call-template>
3157+
</xsl:if>
31083158

31093159
<!-- last, so it reads a toolbar whose facet pills and step chips are already current -->
31103160
<xsl:call-template name="ldh:ViewStatus">
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// The two whole-result-set queries a view used to send on every load, asked only when their answer
2+
// can be seen.
3+
//
4+
// Related-results discovery groups every predicate of every solution - not the page, the whole set -
5+
// and checks that each object is a subject somewhere. The container lookup joins every solution to its
6+
// document and that document's container. Both scale with the result set rather than the page, and on
7+
// a view over a few thousand solutions each took 4-12 s of store time per load (measured 2026-10-19 on
8+
// the LTLOD dataset, a member's votes). Neither shows anything until asked: the pivot chips sit in a
9+
// bar the card draws collapsed, and the container only becomes a Create button for an agent who could
10+
// create there. So discovery waits for the controls toggle, and the lookup for a class to construct and
11+
// an agent who may write.
12+
//
13+
// The requests are told apart by the shape of their SPARQL, because nothing else distinguishes one
14+
// POST to the endpoint from another: discovery is the only query projecting ?objectPredicate (its
15+
// EXISTS over each object's own triples), the lookup the only one joining documents to a
16+
// sioc:has_container ?container. The Create button that the lookup leads to is asserted where it is
17+
// offered, in create.spec.
18+
import { test, expect } from '../../../../lib/console.mjs';
19+
import { goto, loaded } from '../../../../lib/settle.mjs';
20+
import { fixtures, itemTitle } from '../../../../lib/fixtures.mjs';
21+
import { controlToggle } from '../../../../lib/block.mjs';
22+
import { fixtureView, listTitles } from '../../../../lib/view.mjs';
23+
24+
const HAS_CONTAINER = 'http://rdfs.org/sioc/ns#has_container';
25+
const DISCOVERY = /\?objectPredicate/;
26+
const CONTAINER_LOOKUP = /sioc\/ns#has_container> \?container/;
27+
28+
// Every query the page POSTs to its SPARQL endpoint, recorded from before navigation on.
29+
function sparqlQueries(page) {
30+
const sent = [];
31+
page.on('request', request => {
32+
if (request.method() === 'POST' && new URL(request.url()).pathname === '/sparql') sent.push(request.postData() ?? '');
33+
});
34+
return pattern => sent.filter(query => pattern.test(query)).length;
35+
}
36+
37+
// What a view sends next is decided when its results render, so "nothing was sent" can only be read
38+
// once the block has finished loading and the requests it fired have had time to leave.
39+
async function quiet(page) {
40+
await loaded(page);
41+
await page.waitForLoadState('networkidle');
42+
}
43+
44+
test.describe('the queries a view defers', { tag: '@owner' }, () => {
45+
test('discovers related results only once the controls are opened', async ({ page }) => {
46+
const sent = sparqlQueries(page);
47+
await goto(page, fixtures.container);
48+
const block = fixtureView(page);
49+
await expect(listTitles(block).first()).toHaveText(itemTitle(1));
50+
await quiet(page);
51+
52+
// Rendered with the bar collapsed: no discovery, and no chips waiting behind it.
53+
await expect(block.locator('.ldh-pivot-bar')).toHaveClass(/\bis-collapsed\b/);
54+
expect(sent(DISCOVERY)).toBe(0);
55+
await expect(block.locator('.ldh-pivot-pills > *')).toHaveCount(0);
56+
57+
// Opening the controls discovers, once, and the chips arrive.
58+
await controlToggle(block).click();
59+
await expect(block.locator(`.ldh-pivot-bar button.ldh-pivot-pill[data-dir="out"][title="${HAS_CONTAINER}"]`)).toBeVisible();
60+
expect(sent(DISCOVERY)).toBe(1);
61+
62+
// Closing and reopening shows the same result set, so it is not discovered again.
63+
await controlToggle(block).click();
64+
await controlToggle(block).click();
65+
await quiet(page);
66+
expect(sent(DISCOVERY)).toBe(1);
67+
await expect(block.locator(`.ldh-pivot-bar button.ldh-pivot-pill[data-dir="out"][title="${HAS_CONTAINER}"]`)).toBeVisible();
68+
});
69+
70+
test('asks for no container when the view has no class to create', async ({ page }) => {
71+
const sent = sparqlQueries(page);
72+
await goto(page, fixtures.container);
73+
const block = fixtureView(page);
74+
await expect(listTitles(block).first()).toHaveText(itemTitle(1));
75+
await quiet(page);
76+
77+
// An authored view names no class to construct - only an ontology view's property range does -
78+
// so no answer the lookup could give would become a button.
79+
await expect(block).not.toHaveAttribute('data-for-class');
80+
expect(sent(CONTAINER_LOOKUP)).toBe(0);
81+
});
82+
});

0 commit comments

Comments
 (0)