From 06ed862c30c56e7f1874c8ada11432ca3c705aa1 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 12 May 2026 14:31:06 -0500 Subject: [PATCH 1/9] deploy and track --- README.md | 1 - api/Project.js | 4 ++++ api/TPEN.js | 3 +++ interfaces/manage-project/index.html | 12 ++++++++++++ interfaces/manage-project/index.js | 12 ++++++++++++ interfaces/project/index.html | 19 ++++++++++++++++--- 6 files changed, 47 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a8c185e7..560e4325 100644 --- a/README.md +++ b/README.md @@ -84,4 +84,3 @@ Code: Apache License 2.0 — see [LICENSE](./LICENSE) Content (documentation, images, transcriptions, and other non-code assets): Creative Commons Attribution 4.0 International — see [LICENSE-CONTENT.md](./LICENSE-CONTENT.md) If you redistribute or modify this project, please include these license files and preserve copyright notices. - diff --git a/api/Project.js b/api/Project.js index 889872db..5070ca53 100644 --- a/api/Project.js +++ b/api/Project.js @@ -89,6 +89,8 @@ export default class Project { async fetch() { const AUTH_TOKEN = TPEN.getAuthorization() ?? TPEN.login() + // [tpen-race B0] Project.fetch() invoked. Issue #541 diagnostic. + console.log(`%c[tpen-race B0]%c Project.fetch() invoked for ${this._id} @${performance.now().toFixed(1)}ms`, 'color:#0066cc;font-weight:bold', 'color:inherit') try { return await fetch(`${TPEN.servicesURL}/project/${this._id}`, { method: 'GET', @@ -106,6 +108,8 @@ export default class Project { Object.assign(this, data) this.interfaces = this.interfaces?.[this.#getInterfacesNamespace()] ?? this.interfaces?.["*"] this.#isLoaded = true + // [tpen-race B] About to dispatch tpen-project-loaded. Issue #541 diagnostic. + console.log(`%c[tpen-race B]%c dispatching tpen-project-loaded for ${this._id} @${performance.now().toFixed(1)}ms`, 'color:#0066cc;font-weight:bold', 'color:inherit') eventDispatcher.dispatch("tpen-project-loaded", this) return this }) diff --git a/api/TPEN.js b/api/TPEN.js index 56373a16..8705f4ea 100644 --- a/api/TPEN.js +++ b/api/TPEN.js @@ -70,8 +70,11 @@ class Tpen { }) if (this.screen.projectInQuery) { + // [tpen-race A] TPEN constructor schedules the project fetch. Issue #541 diagnostic. + console.log(`%c[tpen-race A]%c fetch-scheduled in TPEN ctor; projectInQuery=${this.screen.projectInQuery} @${performance.now().toFixed(1)}ms`, 'color:#e07b00;font-weight:bold', 'color:inherit') try { import('./Project.js').then(module => { + console.log(`%c[tpen-race A2]%c Project.js dynamic import resolved; calling fetch() @${performance.now().toFixed(1)}ms`, 'color:#e07b00;font-weight:bold', 'color:inherit') new module.default(this.screen.projectInQuery).fetch() }) } catch (error) { diff --git a/interfaces/manage-project/index.html b/interfaces/manage-project/index.html index a482d9a8..72659f1e 100644 --- a/interfaces/manage-project/index.html +++ b/interfaces/manage-project/index.html @@ -83,7 +83,19 @@

Export & Links

import CheckPermissions from '../../components/check-permissions/checkPermissions.js' import TPEN from '../../api/TPEN.js' + // [tpen-race C] /project/manage inline-script about to register listener. Issue #541 diagnostic. + const _alreadyLoaded = !!TPEN.activeProject?._createdAt + console.log(`%c[tpen-race C]%c /project/manage inline-script registering tpen-project-loaded listener; activeProject._createdAt=${_alreadyLoaded ? TPEN.activeProject._createdAt : 'UNSET'} @${performance.now().toFixed(1)}ms`, _alreadyLoaded ? 'color:#cc0000;font-weight:bold' : 'color:#008800;font-weight:bold', 'color:inherit') + let _listenerFired = false + setTimeout(() => { + if (!_listenerFired) { + console.log(`%c[tpen-race !]%c /project/manage inline LISTENER NEVER FIRED — race lost @${performance.now().toFixed(1)}ms`, 'color:#fff;background:#cc0000;font-weight:bold;padding:2px 4px', 'color:inherit') + } + }, 4000) + TPEN.eventDispatcher.on('tpen-project-loaded', ev => { + _listenerFired = true + console.log(`%c[tpen-race D]%c /project/manage inline listener fired @${performance.now().toFixed(1)}ms`, 'color:#008800;font-weight:bold', 'color:inherit') const goParse = document.getElementById("goParse") const goTranscribe = document.getElementById("goTranscribe") if (goParse diff --git a/interfaces/manage-project/index.js b/interfaces/manage-project/index.js index a4367536..db7b7ad2 100644 --- a/interfaces/manage-project/index.js +++ b/interfaces/manage-project/index.js @@ -13,8 +13,20 @@ import { confirmAction } from "../../utilities/confirmAction.js" const container = document.body TPEN.attachAuthentication(container) +// [tpen-race C] manage-project/index.js about to register listener. Issue #541 diagnostic. +const _alreadyLoaded = !!TPEN.activeProject?._createdAt +console.log(`%c[tpen-race C]%c manage-project/index.js registering tpen-project-loaded listener; activeProject._createdAt=${_alreadyLoaded ? TPEN.activeProject._createdAt : 'UNSET'} @${performance.now().toFixed(1)}ms`, _alreadyLoaded ? 'color:#cc0000;font-weight:bold' : 'color:#008800;font-weight:bold', 'color:inherit') +let _mpListenerFired = false +setTimeout(() => { + if (!_mpListenerFired) { + console.log(`%c[tpen-race !]%c manage-project/index.js LISTENER NEVER FIRED — race lost @${performance.now().toFixed(1)}ms`, 'color:#fff;background:#cc0000;font-weight:bold;padding:2px 4px', 'color:inherit') + } +}, 4000) + // Single consolidated listener for project loaded event TPEN.eventDispatcher.on('tpen-project-loaded', () => { + _mpListenerFired = true + console.log(`%c[tpen-race D]%c manage-project/index.js listener fired @${performance.now().toFixed(1)}ms`, 'color:#008800;font-weight:bold', 'color:inherit') const projectID = TPEN.screen.projectInQuery // Set href for navigation links diff --git a/interfaces/project/index.html b/interfaces/project/index.html index bd17b817..cad0b1cf 100644 --- a/interfaces/project/index.html +++ b/interfaces/project/index.html @@ -93,15 +93,22 @@

Project Details Project Details Project Details import TPEN from '../../api/TPEN.js' import CheckPermissions from '../../components/check-permissions/checkPermissions.js' + import { whenProjectReady } from '../../utilities/projectReady.js' // [tpen-race C] /project inline-script about to register listener. Issue #541 diagnostic. const _alreadyLoaded = !!TPEN.activeProject?._createdAt console.log(`%c[tpen-race C]%c /project inline-script registering tpen-project-loaded listener; activeProject._createdAt=${_alreadyLoaded ? TPEN.activeProject._createdAt : 'UNSET'} @${performance.now().toFixed(1)}ms`, _alreadyLoaded ? 'color:#cc0000;font-weight:bold' : 'color:#008800;font-weight:bold', 'color:inherit') let _listenerFired = false - TPEN.eventDispatcher.on('tpen-project-loaded', (ev) => { + whenProjectReady((ev) => { _listenerFired = true console.log(`%c[tpen-race D]%c /project listener fired @${performance.now().toFixed(1)}ms`, 'color:#008800;font-weight:bold', 'color:inherit') const goParse = document.getElementById("goParse") diff --git a/utilities/projectReady.js b/utilities/projectReady.js index f049086f..f1f3a2f5 100644 --- a/utilities/projectReady.js +++ b/utilities/projectReady.js @@ -11,3 +11,28 @@ export const onProjectReady = (ctx, handler, eventName = 'tpen-project-loaded') TPEN.eventDispatcher.on(eventName, bound) return () => TPEN.eventDispatcher.off(eventName, bound) } + +/** + * Context-free variant of {@link onProjectReady} for inline ` diff --git a/interfaces/manage-project/index.html b/interfaces/manage-project/index.html index 72659f1e..fffb0615 100644 --- a/interfaces/manage-project/index.html +++ b/interfaces/manage-project/index.html @@ -81,21 +81,9 @@

Export & Links

diff --git a/interfaces/project/metadata.html b/interfaces/project/metadata.html index 89e9e751..61afd74e 100644 --- a/interfaces/project/metadata.html +++ b/interfaces/project/metadata.html @@ -89,10 +89,10 @@

Edit Project Metadata

Go to Project Management From 1d9e333f1c112f3d68dd14f78bcecdc2be128b0b Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 13 May 2026 10:31:29 -0500 Subject: [PATCH 5/9] Changes during review and testing. --- .../check-permissions/checkPermissions.js | 3 +- .../check-permissions/permission-match.js | 2 +- utilities/__tests__/projectReady.test.js | 57 ++++++++++++++++++- utilities/projectReady.js | 4 +- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/components/check-permissions/checkPermissions.js b/components/check-permissions/checkPermissions.js index 56f00575..c7469f3a 100644 --- a/components/check-permissions/checkPermissions.js +++ b/components/check-permissions/checkPermissions.js @@ -17,13 +17,14 @@ import TPEN from '../../api/TPEN.js' import { getUserFromToken } from '../../components/iiif-tools/index.js' import { permissionMatch } from "../../components/check-permissions/permission-match.js" +import { whenProjectReady } from "../../utilities/projectReady.js" class checkPermissions { #project #userId constructor() { - TPEN.eventDispatcher.on('tpen-project-loaded', (ev) => { + whenProjectReady((ev) => { this.#project = ev.detail this.#userId = getUserFromToken(TPEN.getAuthorization()) }) diff --git a/components/check-permissions/permission-match.js b/components/check-permissions/permission-match.js index dca6fb5d..5f2d5f91 100644 --- a/components/check-permissions/permission-match.js +++ b/components/check-permissions/permission-match.js @@ -40,7 +40,7 @@ const ENTITIES = [ "*", "ANY" ] -whenProjectReady(ev => checkElements(ev.detail)) +queueMicrotask(() => whenProjectReady(ev => checkElements(ev.detail))) /** * Gather all elements with the tpen-view or tpen-edit attributes. diff --git a/utilities/__tests__/projectReady.test.js b/utilities/__tests__/projectReady.test.js index 3851a0c4..afda3959 100644 --- a/utilities/__tests__/projectReady.test.js +++ b/utilities/__tests__/projectReady.test.js @@ -23,7 +23,7 @@ const mockDispatcher = { const { default: TPEN } = await import('../../api/TPEN.js') TPEN.eventDispatcher = mockDispatcher -const { onProjectReady } = await import('../projectReady.js') +const { onProjectReady, whenProjectReady } = await import('../projectReady.js') describe('onProjectReady', () => { afterEach(() => { @@ -63,3 +63,58 @@ describe('onProjectReady', () => { }) }) }) + +describe('whenProjectReady', () => { + afterEach(() => { + mockDispatcher._handlers.clear() + TPEN.activeProject = undefined + }) + + it('subscribes handler to tpen-project-loaded event', () => { + let fired = 0 + whenProjectReady(() => { fired++ }) + mockDispatcher.dispatch('tpen-project-loaded', { _id: 'p1' }) + assert.equal(fired, 1) + }) + + it('invokes synchronously with synthetic event when project already loaded', () => { + const project = { _createdAt: Date.now(), _id: 'p1' } + TPEN.activeProject = project + let received + whenProjectReady(ev => { received = ev }) + assert.deepEqual(received, { detail: project }) + }) + + it('does not invoke synchronously when project is not loaded', () => { + let fired = 0 + whenProjectReady(() => { fired++ }) + assert.equal(fired, 0) + }) + + it('returns an unsubscribe function', () => { + let fired = 0 + const unsub = whenProjectReady(() => { fired++ }) + unsub() + mockDispatcher.dispatch('tpen-project-loaded', { _id: 'p1' }) + assert.equal(fired, 0) + }) + + it('returns a no-op when handler is missing', () => { + assert.doesNotThrow(() => { whenProjectReady(null)?.() }) + }) + + it('logs and recovers when sync handler throws', () => { + TPEN.activeProject = { _createdAt: Date.now(), _id: 'p1' } + const originalError = console.error + let logged + console.error = (...args) => { logged = args } + try { + assert.doesNotThrow(() => { + whenProjectReady(() => { throw new Error('boom') }) + }) + assert.ok(logged?.[0]?.includes('[whenProjectReady]')) + } finally { + console.error = originalError + } + }) +}) diff --git a/utilities/projectReady.js b/utilities/projectReady.js index f1f3a2f5..48c92654 100644 --- a/utilities/projectReady.js +++ b/utilities/projectReady.js @@ -32,7 +32,9 @@ export const whenProjectReady = (handler, eventName = 'tpen-project-loaded') => if (TPEN.activeProject?._createdAt) { handler({ detail: TPEN.activeProject }) } - } catch (_) {} + } catch (err) { + console.error('[whenProjectReady] handler threw during sync invocation:', err) + } TPEN.eventDispatcher.on(eventName, handler) return () => TPEN.eventDispatcher.off(eventName, handler) } From 7e3abb5f14388cd2029162081e9a199e917933e5 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 13 May 2026 11:06:47 -0500 Subject: [PATCH 6/9] documentation nits --- components/check-permissions/permission-match.js | 3 +++ utilities/projectReady.js | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/components/check-permissions/permission-match.js b/components/check-permissions/permission-match.js index 5f2d5f91..037dc26f 100644 --- a/components/check-permissions/permission-match.js +++ b/components/check-permissions/permission-match.js @@ -40,6 +40,9 @@ const ENTITIES = [ "*", "ANY" ] +// Defer one microtask so any tpen-view/tpen-edit elements added by +// sibling modules during their own evaluation are present in the DOM +// before checkElements scans for them. queueMicrotask(() => whenProjectReady(ev => checkElements(ev.detail))) /** diff --git a/utilities/projectReady.js b/utilities/projectReady.js index 48c92654..e00e79fd 100644 --- a/utilities/projectReady.js +++ b/utilities/projectReady.js @@ -18,10 +18,6 @@ export const onProjectReady = (ctx, handler, eventName = 'tpen-project-loaded') * called, the handler is invoked synchronously with a synthetic * `{ detail: TPEN.activeProject }` event so existing `ev.detail.*` handler bodies * keep working. Also subscribes for any future `tpen-project-loaded` dispatch. - * - * Closes the race described in issue #541, where the dispatch can complete before - * an inline-script's `eventDispatcher.on(...)` registration runs. - * * @param {(ev: { detail: any }) => void} handler * @param {string} [eventName='tpen-project-loaded'] * @returns {() => void} unsubscribe From 9d3816d242ab99eb21a4e652fedac4d11f813dab Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 13 May 2026 11:28:50 -0500 Subject: [PATCH 7/9] changes during review --- utilities/__tests__/projectReady.test.js | 8 ++++++++ utilities/projectReady.js | 15 +++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/utilities/__tests__/projectReady.test.js b/utilities/__tests__/projectReady.test.js index afda3959..208d891f 100644 --- a/utilities/__tests__/projectReady.test.js +++ b/utilities/__tests__/projectReady.test.js @@ -103,6 +103,14 @@ describe('whenProjectReady', () => { assert.doesNotThrow(() => { whenProjectReady(null)?.() }) }) + it('does not also receive future dispatches after a sync fire', () => { + TPEN.activeProject = { _createdAt: Date.now(), _id: 'p1' } + let fired = 0 + whenProjectReady(() => { fired++ }) + mockDispatcher.dispatch('tpen-project-loaded', { _id: 'p2' }) + assert.equal(fired, 1) + }) + it('logs and recovers when sync handler throws', () => { TPEN.activeProject = { _createdAt: Date.now(), _id: 'p1' } const originalError = console.error diff --git a/utilities/projectReady.js b/utilities/projectReady.js index e00e79fd..e36c6d11 100644 --- a/utilities/projectReady.js +++ b/utilities/projectReady.js @@ -17,19 +17,22 @@ export const onProjectReady = (ctx, handler, eventName = 'tpen-project-loaded') * blocks that have no element to bind to. If the project is already loaded when * called, the handler is invoked synchronously with a synthetic * `{ detail: TPEN.activeProject }` event so existing `ev.detail.*` handler bodies - * keep working. Also subscribes for any future `tpen-project-loaded` dispatch. + * keep working, and no listener is registered. Otherwise, subscribes for the + * next `tpen-project-loaded` dispatch. The sync path and the listener are + * mutually exclusive — the handler is invoked exactly once for the load. * @param {(ev: { detail: any }) => void} handler * @param {string} [eventName='tpen-project-loaded'] - * @returns {() => void} unsubscribe + * @returns {() => void} unsubscribe (no-op when invoked synchronously) */ export const whenProjectReady = (handler, eventName = 'tpen-project-loaded') => { if (typeof handler !== 'function') return () => {} - try { - if (TPEN.activeProject?._createdAt) { + if (TPEN.activeProject?._createdAt) { + try { handler({ detail: TPEN.activeProject }) + } catch (err) { + console.error('[whenProjectReady] handler threw during sync invocation:', err) } - } catch (err) { - console.error('[whenProjectReady] handler threw during sync invocation:', err) + return () => {} } TPEN.eventDispatcher.on(eventName, handler) return () => TPEN.eventDispatcher.off(eventName, handler) From 4f7f2bc9112760269ed409f6a8d392a5628b4fc4 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 13 May 2026 11:54:00 -0500 Subject: [PATCH 8/9] Line up functions to avoid a rare double firing --- utilities/projectReady.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/utilities/projectReady.js b/utilities/projectReady.js index e36c6d11..1ee01e35 100644 --- a/utilities/projectReady.js +++ b/utilities/projectReady.js @@ -3,11 +3,14 @@ import TPEN from "../api/TPEN.js" export const onProjectReady = (ctx, handler, eventName = 'tpen-project-loaded') => { if (!ctx || typeof handler !== 'function') return () => {} const bound = handler.bind(ctx) - try { - if (TPEN.activeProject?._createdAt) { + if (TPEN.activeProject?._createdAt) { + try { bound() + } catch (err) { + console.error('[onProjectReady] handler threw during sync invocation:', err) } - } catch (_) {} + return () => {} + } TPEN.eventDispatcher.on(eventName, bound) return () => TPEN.eventDispatcher.off(eventName, bound) } From 893e7d625a2b5c348e33bff1be378eb663d5d0a9 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 13 May 2026 12:15:23 -0500 Subject: [PATCH 9/9] add documentation to onProjectReady --- utilities/projectReady.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/utilities/projectReady.js b/utilities/projectReady.js index 1ee01e35..44cebf66 100644 --- a/utilities/projectReady.js +++ b/utilities/projectReady.js @@ -1,5 +1,18 @@ import TPEN from "../api/TPEN.js" +/** + * Bind `handler` to `ctx` and invoke it when the active project is ready. + * Intended for component lifecycle use where the handler needs a `this` + * context (typically a custom element). If the project is already loaded + * when called, the bound handler is invoked synchronously and no listener + * is registered. Otherwise, subscribes for the next `tpen-project-loaded` + * dispatch. The sync path and the listener are mutually exclusive — the + * handler is invoked exactly once for the load. + * @param {object} ctx - Object to bind the handler to (e.g. a component instance). + * @param {(this: object, ev?: { detail: any }) => void} handler + * @param {string} [eventName='tpen-project-loaded'] + * @returns {() => void} unsubscribe (no-op when invoked synchronously or when ctx/handler is missing) + */ export const onProjectReady = (ctx, handler, eventName = 'tpen-project-loaded') => { if (!ctx || typeof handler !== 'function') return () => {} const bound = handler.bind(ctx)