From 153f7fa9418145c30208ac7ad4a9b126e4c0e0a6 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 13:33:31 +1100 Subject: [PATCH 1/7] prevent getUserRoles called twice --- src/login/login.ts | 144 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 129 insertions(+), 15 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index e4af9578..62ff1ee9 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -65,6 +65,12 @@ const { deleteTypeIndexRegistration } = solidLogicSingleton.typeIndex +// Dedupe/caching for preference loading across repeated UI callers. +const ensureLoadedPreferencesInFlight = new Map>() +const cachedPreferencesFileByWebId = new Map() +const getUserRolesInFlight = new Map>>() +const cachedUserRolesByWebId = new Map>() + /** * Resolves with the logged in user's WebID * @@ -83,6 +89,7 @@ export function ensureLoggedIn (context: AuthenticationContext): Promise { if (context.preferencesFile) return Promise.resolve(context) // already done + const webId = context?.me?.uri + if (webId) { + const cachedPreferencesFile = cachedPreferencesFileByWebId.get(webId) + if (cachedPreferencesFile) { + context.preferencesFile = cachedPreferencesFile + return context + } + + const inFlight = ensureLoadedPreferencesInFlight.get(webId) + if (inFlight) { + const resolved = await inFlight + context.preferencesFile = resolved.preferencesFile + context.preferencesFileError = resolved.preferencesFileError + return context + } + } + + const run = (async (): Promise => { + // const statusArea = context.statusArea || context.div || null let progressDisplay /* COMPLAIN FUNCTION NOT USED/TAKING IT OUT FOR NOW @@ -163,7 +189,24 @@ export async function ensureLoadedPreferences ( throw new Error(`(via loadPrefs) ${err}`) } } - return context + return context + })() + + if (webId) { + ensureLoadedPreferencesInFlight.set(webId, run) + } + + try { + const resolved = await run + if (webId && resolved.preferencesFile) { + cachedPreferencesFileByWebId.set(webId, resolved.preferencesFile) + } + return resolved + } finally { + if (webId && ensureLoadedPreferencesInFlight.get(webId) === run) { + ensureLoadedPreferencesInFlight.delete(webId) + } + } } /** @@ -387,11 +430,52 @@ function signInOrSignUpBox ( signInPopUpButton.setAttribute('value', 'Log in') signInPopUpButton.setAttribute('style', `${signInButtonStyle}${style.headerBannerLoginInput}` + style.signUpBackground) - authSession.events.on('login', () => { + authSession.events.on('login', async () => { const me = authn.currentUser() // const sessionInfo = authSession.info // if (sessionInfo && sessionInfo.isLoggedIn) { if (me) { + // Ensure preferences and related settings files are initialized before + // resolving login boxes, to avoid transient 404s for settings resources. + let preferencesFile: NamedNode | undefined + try { + const ensured = await ensureLoadedPreferences({ me }) + preferencesFile = ensured.preferencesFile as NamedNode | undefined + } catch (err) { + debug.warn('Failed to initialize preferences after login', err) + } + + // Refresh key resources after login with authenticated fetch, but avoid + // destructive clears or forced navigation that can hide existing entries. + try { + const storageRoot = store.any(me, ns.space('storage')) as NamedNode | null + if (storageRoot) { + await store.fetcher.load(storageRoot, { force: true }) + } + + if (preferencesFile) { + const settingsContainer = preferencesFile.doc().dir() + if (settingsContainer) { + await store.fetcher.load(settingsContainer, { force: true }) + + // Some servers or cached container states may omit settings from + // the visible root listing even when the container exists. + // Ensure folder UIs can discover it from local store state. + if (storageRoot && + !store.holds(storageRoot, ns.ldp('contains'), settingsContainer, storageRoot.doc())) { + store.add(storageRoot, ns.ldp('contains'), settingsContainer, storageRoot.doc()) + } + } + } + + const currentUri = (dom.getElementById('UserURI') as HTMLInputElement | null)?.value + if (currentUri) { + await store.fetcher.load(store.sym(currentUri), { force: true }) + } + } catch (err) { + debug.warn('Failed to refresh authenticated resources after login', err) + } + // const webIdURI = sessionInfo.webId const webIdURI = me.uri // setUserCallback(webIdURI) @@ -1047,26 +1131,56 @@ export function newAppInstance ( * and/or a developer */ export async function getUserRoles (): Promise> { + const sessionInfo = authSession.info + if (!sessionInfo?.isLoggedIn || !sessionInfo?.webId) { + return [] + } + const currentUser = authn.currentUser() - if (!currentUser) { + if (!currentUser || currentUser.uri !== sessionInfo.webId) { return [] } + const webId = currentUser.uri + const cachedUserRoles = cachedUserRolesByWebId.get(webId) + if (cachedUserRoles) { + return cachedUserRoles + } + + const inFlight = getUserRolesInFlight.get(webId) + if (inFlight) { + return inFlight + } + + const run = (async (): Promise> => { + + try { + const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) + if (!preferencesFile || preferencesFileError) { + throw new Error(preferencesFileError) + } + return solidLogicSingleton.store.each( + me, + ns.rdf('type'), + null, + preferencesFile.doc() + ) as NamedNode[] + } catch (error) { + debug.warn('Unable to fetch your preferences - this was the error: ', error) + } + return [] + })() + + getUserRolesInFlight.set(webId, run) try { - const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) - if (!preferencesFile || preferencesFileError) { - return [] + const roles = await run + cachedUserRolesByWebId.set(webId, roles) + return roles + } finally { + if (getUserRolesInFlight.get(webId) === run) { + getUserRolesInFlight.delete(webId) } - return solidLogicSingleton.store.each( - me, - ns.rdf('type'), - null, - preferencesFile.doc() - ) as NamedNode[] - } catch (error) { - debug.warn('Unable to fetch your preferences - this was the error: ', error) } - return [] } /** From 44fbf90d912f74e8c4853c1de7c78d1337617316 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 14:10:58 +1100 Subject: [PATCH 2/7] fix login tests --- src/login/login.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index b97b92f2..568b730e 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1131,12 +1131,12 @@ export function newAppInstance ( * and/or a developer */ export async function getUserRoles (): Promise> { + const currentUser = authn.currentUser() const sessionInfo = authSession.info if (!sessionInfo?.isLoggedIn || !sessionInfo?.webId) { return [] } - const currentUser = authn.currentUser() if (!currentUser || currentUser.uri !== sessionInfo.webId) { return [] } @@ -1157,7 +1157,7 @@ export async function getUserRoles (): Promise> { try { const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) if (!preferencesFile || preferencesFileError) { - throw new Error(preferencesFileError) || 'Unable to load user preferences file.') + throw new Error(preferencesFileError || 'Unable to load user preferences file.') } return solidLogicSingleton.store.each( me, From 41594c4f6e43d82e306b5b3df71fe8e4f1bb0131 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 17:54:04 +1100 Subject: [PATCH 3/7] handle empty roles no pref --- src/login/login.ts | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index 568b730e..3b6878dd 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1132,11 +1132,11 @@ export function newAppInstance ( */ export async function getUserRoles (): Promise> { const currentUser = authn.currentUser() - const sessionInfo = authSession.info + const sessionInfo = authSession.info if (!sessionInfo?.isLoggedIn || !sessionInfo?.webId) { return [] } - + if (!currentUser || currentUser.uri !== sessionInfo.webId) { return [] } @@ -1153,22 +1153,16 @@ export async function getUserRoles (): Promise> { } const run = (async (): Promise> => { - - try { - const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) - if (!preferencesFile || preferencesFileError) { - throw new Error(preferencesFileError || 'Unable to load user preferences file.') - } - return solidLogicSingleton.store.each( - me, - ns.rdf('type'), - null, - preferencesFile.doc() - ) as NamedNode[] - } catch (error) { - debug.warn('Unable to fetch your preferences - this was the error: ', error) + const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) + if (!preferencesFile || preferencesFileError) { + throw new Error(preferencesFileError || 'Unable to load user preferences file.') } - return [] + return solidLogicSingleton.store.each( + me, + ns.rdf('type'), + null, + preferencesFile.doc() + ) as NamedNode[] })() getUserRolesInFlight.set(webId, run) @@ -1176,6 +1170,9 @@ export async function getUserRoles (): Promise> { const roles = await run cachedUserRolesByWebId.set(webId, roles) return roles + } catch (error) { + debug.warn('Unable to fetch your preferences - this was the error: ', error) + return [] } finally { if (getUserRolesInFlight.get(webId) === run) { getUserRolesInFlight.delete(webId) From d0738f2c8b593424f30492d61120a4b2892b8296 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 18:00:09 +1100 Subject: [PATCH 4/7] do not just wait for setting load all --- src/login/login.ts | 47 +++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index 3b6878dd..e757b5ef 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -449,29 +449,50 @@ function signInOrSignUpBox ( // destructive clears or forced navigation that can hide existing entries. try { const storageRoot = store.any(me, ns.space('storage')) as NamedNode | null - if (storageRoot) { - await store.fetcher.load(storageRoot, { force: true }) + const resourcesToReload: NamedNode[] = [] + const seenResources = new Set() + const queueResource = (resource: NamedNode | null | undefined): void => { + if (!resource || seenResources.has(resource.uri)) return + seenResources.add(resource.uri) + resourcesToReload.push(resource) } + queueResource(me.doc()) + queueResource(storageRoot) if (preferencesFile) { - const settingsContainer = preferencesFile.doc().dir() - if (settingsContainer) { - await store.fetcher.load(settingsContainer, { force: true }) - - // Some servers or cached container states may omit settings from - // the visible root listing even when the container exists. - // Ensure folder UIs can discover it from local store state. - if (storageRoot && - !store.holds(storageRoot, ns.ldp('contains'), settingsContainer, storageRoot.doc())) { - store.add(storageRoot, ns.ldp('contains'), settingsContainer, storageRoot.doc()) + const preferencesDoc = preferencesFile.doc() + const settingsContainer = preferencesDoc.dir() + queueResource(preferencesDoc) + queueResource(settingsContainer) + + // If settings are under the advertised storage root, proactively load + // each ancestor container so folder UIs can discover the path via + // fetched containment data rather than synthesized triples. + if (storageRoot && settingsContainer) { + const normalizedRoot = storageRoot.uri.endsWith('/') ? storageRoot.uri : `${storageRoot.uri}/` + if (settingsContainer.uri.startsWith(normalizedRoot)) { + const relativeParts = settingsContainer.uri + .slice(normalizedRoot.length) + .split('/') + .filter(Boolean) + let cursor = normalizedRoot + for (const part of relativeParts) { + cursor += `${part}/` + queueResource(store.sym(cursor)) + } } } + } const currentUri = (dom.getElementById('UserURI') as HTMLInputElement | null)?.value if (currentUri) { - await store.fetcher.load(store.sym(currentUri), { force: true }) + queueResource(store.sym(currentUri)) } + + await Promise.allSettled( + resourcesToReload.map(resource => store.fetcher.load(resource, { force: true })) + ) } catch (err) { debug.warn('Failed to refresh authenticated resources after login', err) } From a11f9e13caf25e8d613770c058a16949da28bb7a Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 18:04:11 +1100 Subject: [PATCH 5/7] add tests --- test/unit/login/login.test.ts | 134 ++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/test/unit/login/login.test.ts b/test/unit/login/login.test.ts index ceb14bb4..27a7ad53 100644 --- a/test/unit/login/login.test.ts +++ b/test/unit/login/login.test.ts @@ -1,4 +1,68 @@ import * as testLogin from '../../../src/login/login' +import { sym } from 'rdflib' + +function buildSolidLogicMock () { + const loadPreferences = jest.fn() + const loadProfile = jest.fn(async (me) => me?.doc?.() ?? me) + const store = { + each: jest.fn(() => []), + any: jest.fn(() => null), + holds: jest.fn(() => false), + add: jest.fn(), + sym, + fetcher: { + load: jest.fn(async () => undefined) + } + } + + const mockModule = { + AppDetails: class AppDetails {}, + AuthenticationContext: class AuthenticationContext {}, + authn: { + currentUser: jest.fn(() => null), + checkUser: jest.fn(async () => null), + saveUser: jest.fn((user) => user) + }, + authSession: { + info: { isLoggedIn: false, webId: undefined }, + events: { on: jest.fn() }, + login: jest.fn(async () => undefined), + logout: jest.fn(async () => undefined) + }, + CrossOriginForbiddenError: class CrossOriginForbiddenError extends Error {}, + FetchError: class FetchError extends Error { status?: number }, + getSuggestedIssuers: jest.fn(() => []), + NotEditableError: class NotEditableError extends Error {}, + offlineTestID: jest.fn(() => null), + SameOriginForbiddenError: class SameOriginForbiddenError extends Error {}, + UnauthorizedError: class UnauthorizedError extends Error {}, + WebOperationError: class WebOperationError extends Error {}, + store, + solidLogicSingleton: { + store, + profile: { + loadPreferences, + loadProfile + }, + typeIndex: { + getScopedAppInstances: jest.fn(async () => []), + getRegistrations: jest.fn(() => []), + loadAllTypeIndexes: jest.fn(async () => []), + getScopedAppsFromIndex: jest.fn(async () => []), + deleteTypeIndexRegistration: jest.fn(async () => undefined) + } + } + } + + return { mockModule, loadPreferences, store } +} + +function loadLoginWithMock () { + const { mockModule, loadPreferences, store } = buildSolidLogicMock() + jest.doMock('solid-logic', () => mockModule) + const loginModule = require('../../../src/login/login') + return { loginModule, solidLogic: mockModule, loadPreferences, store } +} describe('ensureLoggedIn', () => { afterAll(() => { @@ -16,6 +80,7 @@ describe('getUserRoles', () => { afterEach(() => { jest.restoreAllMocks() jest.resetModules() + jest.clearAllMocks() }) it('returns [] and does not load preferences when current user is missing', async () => { @@ -36,4 +101,73 @@ describe('getUserRoles', () => { expect(roles).toEqual([]) expect(loadPreferencesSpy).not.toHaveBeenCalled() }) + + it('shares in-flight ensureLoadedPreferences work for concurrent callers', async () => { + const { loginModule, solidLogic, loadPreferences } = loadLoginWithMock() + + const me = sym('https://alice.example.com/profile/card#me') + let resolvePreferences: (value: any) => void = () => {} + const preferencesFile = sym('https://alice.example.com/settings/prefs.ttl') + + solidLogic.authn.currentUser.mockReturnValue(me) + loadPreferences.mockImplementation(() => new Promise((resolve) => { + resolvePreferences = resolve + })) + + const p1 = loginModule.ensureLoadedPreferences({ me, publicProfile: me.doc() }) + const p2 = loginModule.ensureLoadedPreferences({ me, publicProfile: me.doc() }) + + await Promise.resolve() + expect(loadPreferences).toHaveBeenCalledTimes(1) + + resolvePreferences(preferencesFile) + const [first, second] = await Promise.all([p1, p2]) + + expect(first.preferencesFile).toEqual(preferencesFile) + expect(second.preferencesFile).toEqual(preferencesFile) + expect(loadPreferences).toHaveBeenCalledTimes(1) + }) + + it('caches successful role lookups per WebID', async () => { + const { loginModule, solidLogic, loadPreferences, store } = loadLoginWithMock() + + const me = sym('https://alice.example.com/profile/card#me') + const preferencesFile = sym('https://alice.example.com/settings/prefs.ttl') + const role = sym('http://example.com/ns#PowerUser') + + solidLogic.authSession.info = { isLoggedIn: true, webId: me.uri } + solidLogic.authn.currentUser.mockReturnValue(me) + loadPreferences.mockResolvedValue(preferencesFile) + store.each.mockReturnValue([role]) + + const first = await loginModule.getUserRoles() + const second = await loginModule.getUserRoles() + + expect(first).toEqual([role]) + expect(second).toEqual([role]) + expect(loadPreferences).toHaveBeenCalledTimes(1) + expect(store.each).toHaveBeenCalledTimes(1) + }) + + it('does not cache failed role lookups', async () => { + const { loginModule, solidLogic, loadPreferences, store } = loadLoginWithMock() + + const me = sym('https://alice.example.com/profile/card#me') + const preferencesFile = sym('https://alice.example.com/settings/prefs.ttl') + const role = sym('http://example.com/ns#Developer') + + solidLogic.authSession.info = { isLoggedIn: true, webId: me.uri } + solidLogic.authn.currentUser.mockReturnValue(me) + loadPreferences.mockRejectedValueOnce(new Error('transient failure')) + loadPreferences.mockResolvedValueOnce(preferencesFile) + store.each.mockReturnValue([role]) + + const first = await loginModule.getUserRoles() + const second = await loginModule.getUserRoles() + + expect(first).toEqual([]) + expect(second).toEqual([role]) + expect(loadPreferences).toHaveBeenCalledTimes(2) + expect(store.each).toHaveBeenCalledTimes(1) + }) }) From 5d227a0b8dd22cc475af13283fe274a7b3b5c186 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 20:33:34 +1100 Subject: [PATCH 6/7] revert reloading storage --- src/login/login.ts | 64 +--------------------------------------------- 1 file changed, 1 insertion(+), 63 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index e757b5ef..fa8762a5 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -430,73 +430,11 @@ function signInOrSignUpBox ( signInPopUpButton.setAttribute('value', 'Log in') signInPopUpButton.setAttribute('style', `${signInButtonStyle}${style.headerBannerLoginInput}` + style.signUpBackground) - authSession.events.on('login', async () => { + authSession.events.on('login', () => { const me = authn.currentUser() // const sessionInfo = authSession.info // if (sessionInfo && sessionInfo.isLoggedIn) { if (me) { - // Ensure preferences and related settings files are initialized before - // resolving login boxes, to avoid transient 404s for settings resources. - let preferencesFile: NamedNode | undefined - try { - const ensured = await ensureLoadedPreferences({ me }) - preferencesFile = ensured.preferencesFile as NamedNode | undefined - } catch (err) { - debug.warn('Failed to initialize preferences after login', err) - } - - // Refresh key resources after login with authenticated fetch, but avoid - // destructive clears or forced navigation that can hide existing entries. - try { - const storageRoot = store.any(me, ns.space('storage')) as NamedNode | null - const resourcesToReload: NamedNode[] = [] - const seenResources = new Set() - const queueResource = (resource: NamedNode | null | undefined): void => { - if (!resource || seenResources.has(resource.uri)) return - seenResources.add(resource.uri) - resourcesToReload.push(resource) - } - - queueResource(me.doc()) - queueResource(storageRoot) - if (preferencesFile) { - const preferencesDoc = preferencesFile.doc() - const settingsContainer = preferencesDoc.dir() - queueResource(preferencesDoc) - queueResource(settingsContainer) - - // If settings are under the advertised storage root, proactively load - // each ancestor container so folder UIs can discover the path via - // fetched containment data rather than synthesized triples. - if (storageRoot && settingsContainer) { - const normalizedRoot = storageRoot.uri.endsWith('/') ? storageRoot.uri : `${storageRoot.uri}/` - if (settingsContainer.uri.startsWith(normalizedRoot)) { - const relativeParts = settingsContainer.uri - .slice(normalizedRoot.length) - .split('/') - .filter(Boolean) - let cursor = normalizedRoot - for (const part of relativeParts) { - cursor += `${part}/` - queueResource(store.sym(cursor)) - } - } - } - - } - - const currentUri = (dom.getElementById('UserURI') as HTMLInputElement | null)?.value - if (currentUri) { - queueResource(store.sym(currentUri)) - } - - await Promise.allSettled( - resourcesToReload.map(resource => store.fetcher.load(resource, { force: true })) - ) - } catch (err) { - debug.warn('Failed to refresh authenticated resources after login', err) - } - // const webIdURI = sessionInfo.webId const webIdURI = me.uri // setUserCallback(webIdURI) From 2fe2b0638370bb746764d55dbec2352a89b50e16 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 20:35:08 +1100 Subject: [PATCH 7/7] fix test --- test/unit/login/login.test.ts | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/unit/login/login.test.ts b/test/unit/login/login.test.ts index 27a7ad53..0aa23e0e 100644 --- a/test/unit/login/login.test.ts +++ b/test/unit/login/login.test.ts @@ -11,6 +11,7 @@ function buildSolidLogicMock () { add: jest.fn(), sym, fetcher: { + requested: {}, load: jest.fn(async () => undefined) } } @@ -170,4 +171,43 @@ describe('getUserRoles', () => { expect(loadPreferences).toHaveBeenCalledTimes(2) expect(store.each).toHaveBeenCalledTimes(1) }) + + it('does not clear cached storage request failures during login UI handling', async () => { + const { loginModule, solidLogic, store } = loadLoginWithMock() + + const me = sym('https://alice.example.com/profile/card#me') + const initialRequested = { + 'https://alice.example.com/settings/': 404, + 'https://alice.example.com/private/notes.ttl': 404, + 'https://other.example.com/resource.ttl': 404 + } + store.fetcher.requested = { ...initialRequested } + + const dom = document.implementation.createHTMLDocument('login-test') + const userUriInput = dom.createElement('input') + userUriInput.id = 'UserURI' + userUriInput.value = 'https://alice.example.com/private/notes.ttl' + dom.body.appendChild(userUriInput) + + solidLogic.authn.currentUser + .mockReturnValueOnce(null) + .mockReturnValue(me) + .mockReturnValue(me) + + const box = loginModule.loginStatusBox(dom, jest.fn()) + dom.body.appendChild(box) + + const loginHandlers = solidLogic.authSession.events.on.mock.calls + .filter(([eventName]) => eventName === 'login') + .map(([, handler]) => handler) + + expect(loginHandlers.length).toBeGreaterThan(0) + for (const handler of loginHandlers) { + await handler() + } + + expect(store.fetcher.requested['https://alice.example.com/settings/']).toBe(404) + expect(store.fetcher.requested['https://alice.example.com/private/notes.ttl']).toBe(404) + expect(store.fetcher.requested['https://other.example.com/resource.ttl']).toBe(404) + }) })