From fac333c81c913d515ad70af53c13f0e9bc8737a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Sun, 29 Mar 2026 07:42:42 +0800 Subject: [PATCH 1/4] fix: handle PendingQueryContext in linkedComponent Show loading spinner while PendingQueryContext resolves instead of warning and returning null. loadData handles null/error results gracefully. Use shared FUSEKI_BASE_URL for test Fuseki port. Co-Authored-By: Claude Opus 4.6 --- .changeset/pending-query-context.md | 5 ++ .../classnames-and-query-context.test.tsx | 75 +++++++++++++++++-- .../react-component-integration.test.tsx | 3 +- src/utils/LinkedComponent.ts | 20 ++++- 4 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 .changeset/pending-query-context.md diff --git a/.changeset/pending-query-context.md b/.changeset/pending-query-context.md new file mode 100644 index 0000000..f643bdc --- /dev/null +++ b/.changeset/pending-query-context.md @@ -0,0 +1,5 @@ +--- +"@_linked/react": patch +--- + +`linkedComponent` now shows a loading spinner while a `PendingQueryContext` resolves instead of logging a warning and returning null. `loadData` handles null/error results gracefully. Fuseki test URL uses shared `FUSEKI_BASE_URL` constant. diff --git a/src/tests/classnames-and-query-context.test.tsx b/src/tests/classnames-and-query-context.test.tsx index ec45081..96fd02e 100644 --- a/src/tests/classnames-and-query-context.test.tsx +++ b/src/tests/classnames-and-query-context.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import {render, cleanup, act} from '@testing-library/react'; import {cl} from '../utils/ClassNames.js'; import {useQueryContext} from '../utils/useQueryContext.js'; -import {getQueryContext, setQueryContext} from '@_linked/core/queries/QueryContext'; +import {getQueryContext, setQueryContext, PendingQueryContext} from '@_linked/core/queries/QueryContext'; import {Shape} from '@_linked/core/shapes/Shape'; import {linkedShape} from '../package.js'; @@ -77,16 +77,14 @@ describe('useQueryContext', () => { }); test('does not set context when value is falsy', async () => { - // Clear any previous context - setQueryContext('emptyCtx', null as any); - await act(async () => { render(); }); const ctx = getQueryContext('emptyCtx'); - // Should still be null since we passed null value - expect(ctx).toBeNull(); + // Should return a PendingQueryContext (not null) whose id is undefined + expect(ctx).toBeInstanceOf(PendingQueryContext); + expect(ctx.id).toBeUndefined(); }); test('updates context when value changes', async () => { @@ -107,3 +105,68 @@ describe('useQueryContext', () => { expect(ctx).not.toBeNull(); }); }); + +// ── PendingQueryContext (lazy resolution) ── + +describe('PendingQueryContext', () => { + afterEach(() => { + cleanup(); + // Clear contexts between tests + setQueryContext('lazyUser', null as any); + }); + + test('getQueryContext returns PendingQueryContext when context is not yet set', () => { + const ctx = getQueryContext('nonExistent'); + expect(ctx).toBeInstanceOf(PendingQueryContext); + expect((ctx as any).contextName).toBe('nonExistent'); + }); + + test('PendingQueryContext.id is undefined when context is not set', () => { + const ctx = getQueryContext('lazyUser'); + expect(ctx.id).toBeUndefined(); + }); + + test('PendingQueryContext.id resolves after setQueryContext is called', () => { + // Get a pending reference BEFORE setting the context + const ctx = getQueryContext('lazyUser'); + expect(ctx.id).toBeUndefined(); + + // Now set the context (simulates what useAuth does after login) + setQueryContext('lazyUser', {id: 'urn:test:user:42'}, TestPerson); + + // The same reference should now resolve to the ID + expect(ctx.id).toBe('urn:test:user:42'); + }); + + test('getQueryContext returns resolved value (not PendingQueryContext) after context is set', () => { + setQueryContext('lazyUser', {id: 'urn:test:user:1'}, TestPerson); + + const ctx = getQueryContext('lazyUser'); + // Should return the actual QueryShape, not a PendingQueryContext + expect(ctx).not.toBeInstanceOf(PendingQueryContext); + expect(ctx.id).toBe('urn:test:user:1'); + }); + + test('PendingQueryContext.id updates when context value changes', () => { + const ctx = getQueryContext('lazyUser'); + expect(ctx.id).toBeUndefined(); + + // Set to first user + setQueryContext('lazyUser', {id: 'urn:test:user:1'}, TestPerson); + expect(ctx.id).toBe('urn:test:user:1'); + + // Update to second user + setQueryContext('lazyUser', {id: 'urn:test:user:2'}, TestPerson); + expect(ctx.id).toBe('urn:test:user:2'); + }); + + test('multiple PendingQueryContext instances for same name all resolve', () => { + const ctx1 = getQueryContext('lazyUser'); + const ctx2 = getQueryContext('lazyUser'); + + setQueryContext('lazyUser', {id: 'urn:test:user:99'}, TestPerson); + + expect(ctx1.id).toBe('urn:test:user:99'); + expect(ctx2.id).toBe('urn:test:user:99'); + }); +}); diff --git a/src/tests/react-component-integration.test.tsx b/src/tests/react-component-integration.test.tsx index 036c00a..f20708d 100644 --- a/src/tests/react-component-integration.test.tsx +++ b/src/tests/react-component-integration.test.tsx @@ -27,6 +27,7 @@ import { deleteTestDataset, loadTestData, clearAllData, + FUSEKI_BASE_URL, } from '@_linked/core/test-helpers/fuseki-test-store'; import {FusekiStore} from '@_linked/core/test-helpers/FusekiStore'; import {setQueryContext} from '@_linked/core/queries/QueryContext'; @@ -91,7 +92,7 @@ beforeAll(async () => { } // Set up store - const store = new FusekiStore('http://localhost:3030', 'nashville-test'); + const store = new FusekiStore(FUSEKI_BASE_URL, 'nashville-test'); LinkedStorage.setDefaultStore(store); // Set up query context diff --git a/src/utils/LinkedComponent.ts b/src/utils/LinkedComponent.ts index 84a177a..0050b1d 100644 --- a/src/utils/LinkedComponent.ts +++ b/src/utils/LinkedComponent.ts @@ -194,7 +194,13 @@ export function createLinkedComponentFn( setLoadingData(sourceId || requestQuery.toJSON().subject); getQueryDispatch().selectQuery(requestQuery.build()).then((result) => { - setQueryResult(result); + // Use empty object when result is null/undefined so the component + // renders with default values instead of showing spinner forever. + setQueryResult(result ?? {}); + setLoadingData(null); + }).catch((err) => { + console.error('linkedComponent loadData failed:', err); + setQueryResult({}); setLoadingData(null); }); } else { @@ -225,7 +231,15 @@ export function createLinkedComponentFn( [queryResult, props.of], ); - if (!linkedProps.source && !actualQuery.toJSON().subject) { + // Resolve the current subject ID — for pending contexts this reads + // from the live global Map, so it updates when auth sets the context. + const resolvedSubjectId = actualQuery.toJSON().subject; + + if (!linkedProps.source && !resolvedSubjectId) { + if (actualQuery.hasPendingContext()) { + // Subject will resolve after auth — show spinner until then. + return createLoadingSpinner(); + } console.warn( 'This component requires a source to be provided (use the property "of"): ' + functionalComponent.name, @@ -243,7 +257,7 @@ export function createLinkedComponentFn( if (usingStorage && !sourceIsValidQResult) { loadData(); } - }, [linkedProps.source?.id]); + }, [linkedProps.source?.id, resolvedSubjectId]); let dataIsLoaded = queryResult || !usingStorage || sourceIsValidQResult; From 0e1ec2dd854acc0e4cc93db1ac9a5670f837f82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Sun, 29 Mar 2026 07:48:45 +0800 Subject: [PATCH 2/4] fix: bump @_linked/core dependency to ^2.2.2 Requires PendingQueryContext and hasPendingContext() from core 2.2.2. Co-Authored-By: Claude Opus 4.6 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1b5f336..2635169 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "test": "npx jest --config jest.config.js" }, "peerDependencies": { - "@_linked/core": "^2.2.1", + "@_linked/core": "^2.2.2", "react": "^18.2.0" }, "dependencies": { @@ -53,7 +53,7 @@ "react-usestateref": "^1.0.8" }, "devDependencies": { - "@_linked/core": "^2.2.1", + "@_linked/core": "^2.2.2", "@_linked/rdf-mem-store": "^1.0.0", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", From eb6b055454748e8671b9a421a6066ce8237393c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Sun, 29 Mar 2026 07:51:01 +0800 Subject: [PATCH 3/4] fix: correct @_linked/core dependency to ^2.2.3 Co-Authored-By: Claude Opus 4.6 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2635169..f3dee63 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "test": "npx jest --config jest.config.js" }, "peerDependencies": { - "@_linked/core": "^2.2.2", + "@_linked/core": "^2.2.3", "react": "^18.2.0" }, "dependencies": { @@ -53,7 +53,7 @@ "react-usestateref": "^1.0.8" }, "devDependencies": { - "@_linked/core": "^2.2.2", + "@_linked/core": "^2.2.3", "@_linked/rdf-mem-store": "^1.0.0", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", From 4fd10d64cb83665d61e99fe88621599fcb2c330a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Sun, 29 Mar 2026 08:02:02 +0800 Subject: [PATCH 4/4] chore: update lockfile to resolve @_linked/core@2.2.3 Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 23000b8..4305ece 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "react-usestateref": "^1.0.8" }, "devDependencies": { - "@_linked/core": "^2.2.1", + "@_linked/core": "^2.2.3", "@_linked/rdf-mem-store": "^1.0.0", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", @@ -32,14 +32,14 @@ "typescript": "^5.7.3" }, "peerDependencies": { - "@_linked/core": "^2.2.1", + "@_linked/core": "^2.2.3", "react": "^18.2.0" } }, "node_modules/@_linked/core": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@_linked/core/-/core-2.2.1.tgz", - "integrity": "sha512-nk+xpN6RWzkparQkcvJkoYg2MDr9TW33eUMcXSknxnHTTjUHe/Wla1BIaupGkHJEgs1m9XWMHHGKpZeShj/sPw==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@_linked/core/-/core-2.2.3.tgz", + "integrity": "sha512-fHi/B3U7ZPl3z5wsluLZlPJYJKt5MRXFTcR2tjlFCuhQrWe7nTAp+69OvCu47fKY/Hcsx33466fsIw3wCz+VtA==", "dev": true, "license": "MIT", "dependencies": {