Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pending-query-context.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@
"test": "npx jest --config jest.config.js"
},
"peerDependencies": {
"@_linked/core": "^2.2.1",
"@_linked/core": "^2.2.3",
"react": "^18.2.0"
},
"dependencies": {
"classnames": "^2.5.1",
"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",
Expand Down
75 changes: 69 additions & 6 deletions src/tests/classnames-and-query-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(<ContextSetter name="emptyCtx" value={null} />);
});

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 () => {
Expand All @@ -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');
});
});
3 changes: 2 additions & 1 deletion src/tests/react-component-integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions src/utils/LinkedComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -243,7 +257,7 @@ export function createLinkedComponentFn(
if (usingStorage && !sourceIsValidQResult) {
loadData();
}
}, [linkedProps.source?.id]);
}, [linkedProps.source?.id, resolvedSubjectId]);

let dataIsLoaded =
queryResult || !usingStorage || sourceIsValidQResult;
Expand Down
Loading