From f95f5a61c794a1636360ef6645c677e5b32a2216 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Mon, 20 Jul 2026 10:52:59 +0000 Subject: [PATCH 1/2] Coalesce concurrent requests for the same schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SchemaRegistry only caches a schema once its fetch resolves, so consumers asking for the same schema while a request was still in flight — parallel branches of the form parser plus the Monaco preload at mount time — each missed the cache and issued their own fetch, loading every schema two or three times. fetchExternalSchema now tracks in-flight requests by URL and shares the pending promise between callers, so each schema is fetched exactly once. loadSchemaSet's strict root fetch joins the same map, keeping its throw-on-failure semantics. Co-Authored-By: Claude Fable 5 --- src/lib/editor/form/utils/schema.ts | 38 ++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/lib/editor/form/utils/schema.ts b/src/lib/editor/form/utils/schema.ts index d03b93e..22159aa 100644 --- a/src/lib/editor/form/utils/schema.ts +++ b/src/lib/editor/form/utils/schema.ts @@ -35,16 +35,34 @@ function getRelativeSchema(parentSchema: Schema, id: string, del = '/'): Schema return path(parentSchema, id, del) } -async function fetchExternalSchema(id: string): Promise { - let schema = SchemaRegistry[id] - if (schema) return schema +// In-flight schema requests, keyed by URL, so that concurrent consumers +// (the form parser branches and the Monaco preload) share a single fetch +// instead of each missing the registry cache and requesting the same +// schema again. +const pendingSchemas: Record> = {} + +function fetchExternalSchemaStrict(id: string): Promise { + const schema = SchemaRegistry[id] + if (schema) return Promise.resolve(schema) + + let req = pendingSchemas[id] + if (!req) { + req = fetchJsonSchema(id) + .then((fetched) => { + SchemaRegistry[id] = fetched + return fetched + }) + .finally(() => { + delete pendingSchemas[id] + }) + pendingSchemas[id] = req + } + return req +} +async function fetchExternalSchema(id: string): Promise { try { - schema = await fetchJsonSchema(id) - - SchemaRegistry[id] = schema - - return schema + return await fetchExternalSchemaStrict(id) } catch (error) { return EMPTY_SCHEMA } @@ -72,9 +90,7 @@ async function fetchSchema(id: string): Promise { // degrade to an empty placeholder instead. export async function loadSchemaSet(url: string): Promise> { const rootId = url.split('#')[0] - if (!SchemaRegistry[rootId]) { - SchemaRegistry[rootId] = await fetchJsonSchema(rootId) - } + await fetchExternalSchemaStrict(rootId) const found = new Map() const queue = [rootId] From 2c5b58cb76cf2d2d63c304ced3e79a7ecd8b5bff Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Mon, 20 Jul 2026 12:53:23 +0000 Subject: [PATCH 2/2] Keep the configured API endpoint when editors mount without one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The apiBaseUrl prop defaulted to the public gobl.dev service, so any editor mounted without an explicit endpoint — such as a nested correct or headers modal — reset the shared client back to the default, undoing the embedder's configuration and sending stray requests to gobl.dev. Default the prop to an empty string instead, which setApiBaseUrl treats as a no-op, so editors without an explicit endpoint inherit whatever is already configured. Co-Authored-By: Claude Fable 5 --- src/lib/EnvelopeEditor.svelte | 8 +++++--- src/lib/ObjectEditor.svelte | 8 +++++--- src/lib/types/editor.ts | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/lib/EnvelopeEditor.svelte b/src/lib/EnvelopeEditor.svelte index 8162429..287f0f5 100644 --- a/src/lib/EnvelopeEditor.svelte +++ b/src/lib/EnvelopeEditor.svelte @@ -4,7 +4,7 @@ import { envelopeDocumentJSON } from '$lib/helpers/envelope' import EditorCode from './editor/code/EditorCode.svelte' import EditorForm from './editor/form/EditorForm.svelte' - import { isEnvelope, setApiBaseUrl, DEFAULT_API_BASE_URL } from '$lib/gobl/client' + import { isEnvelope, setApiBaseUrl } from '$lib/gobl/client' import { problemSeverityMap } from './editor/EditorProblem.js' import * as actions from './editor/actions' import type { BuildOptions, DocumentHeader, State } from './types/editor' @@ -19,7 +19,7 @@ let { jsonSchemaURL = '', - apiBaseUrl = DEFAULT_API_BASE_URL, + apiBaseUrl = '', data = $bindable(''), state: initialState = $bindable('init'), problems = $bindable([]), @@ -41,7 +41,9 @@ }: EnvelopeEditorProps = $props() // Configure the GOBL API endpoint before any operation runs. The initial - // value is applied eagerly during init; the effect keeps it in sync. + // value is applied eagerly during init; the effect keeps it in sync. An + // empty prop is a no-op so an editor without an explicit endpoint inherits + // the currently-configured one instead of resetting it to the default. // svelte-ignore state_referenced_locally setApiBaseUrl(apiBaseUrl) $effect(() => { diff --git a/src/lib/ObjectEditor.svelte b/src/lib/ObjectEditor.svelte index dc1fc2d..accb298 100644 --- a/src/lib/ObjectEditor.svelte +++ b/src/lib/ObjectEditor.svelte @@ -2,13 +2,13 @@ import DynamicForm from '$lib/editor/form/DynamicForm.svelte' import { getUIModel } from '$lib/editor/form/utils/model' import type { SchemaValue } from '$lib/editor/form/utils/schema' - import { setApiBaseUrl, DEFAULT_API_BASE_URL } from '$lib/gobl/client' + import { setApiBaseUrl } from '$lib/gobl/client' import { createBuilderContext } from './store/builder' import type { ObjectEditorProps } from './types/editor' let { jsonSchemaURL = '', - apiBaseUrl = DEFAULT_API_BASE_URL, + apiBaseUrl = '', data = undefined, id = `editor-${Math.random().toString(36).slice(2, 7)}`, readOnly = false, @@ -16,7 +16,9 @@ }: ObjectEditorProps = $props() // Configure the GOBL API endpoint before any schema is fetched. The initial - // value is applied eagerly during init; the effect keeps it in sync. + // value is applied eagerly during init; the effect keeps it in sync. An + // empty prop is a no-op so nested editors (e.g. the correct/headers modals) + // inherit the embedder's endpoint instead of resetting it to the default. // svelte-ignore state_referenced_locally setApiBaseUrl(apiBaseUrl) $effect(() => { diff --git a/src/lib/types/editor.ts b/src/lib/types/editor.ts index f7131ad..65cdb92 100644 --- a/src/lib/types/editor.ts +++ b/src/lib/types/editor.ts @@ -205,9 +205,11 @@ export interface EnvelopeEditorProps { // Used for JSON Schema validation within Monaco Editor. When set, this should be the JSON Schema URL of a GOBL document, e.g. an invoice. Not an envelope. jsonSchemaURL?: string // Base URL of the GOBL API used for build, sign, validate, correct, - // replicate, keygen and schema operations. Defaults to the public service at - // `https://gobl.dev/v0`. Embedders may point this at a same-origin path - // (e.g. `/api/gobl`) that proxies the GOBL API and adds authentication. + // replicate, keygen and schema operations. Embedders may point this at a + // same-origin path (e.g. `/api/gobl`) that proxies the GOBL API and adds + // authentication. When left unset the editor keeps whatever endpoint is + // already configured (the public `https://gobl.dev/v0` service initially), + // so nested editors never reset an embedder's choice. apiBaseUrl?: string // Data is used for setting editor contents. Note: there is "one way" binding; // e.g. you can set data but changes are not bound to the parent. Use the