This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5673-7a9730bee0986e6d51e29aa28936665336aa7b44 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 6182c6c81df93fabf9ea22eb1350db1642ce9bb0 Author: Matthew B. <[email protected]> AuthorDate: Tue Jun 23 15:33:03 2026 -0700 refactor(frontend): preserve config source in GuiConfigService instead of one flat map (#5673) > Stacked on #5545 (adds the `/config/amber` endpoint and the `amber` config source this PR namespaces). Base diff includes #5545 until it merges; review the last commit only, or after #5545 lands. ### What changes were proposed in this PR? - Changed `GuiConfigService` to store each endpoint's payload under its own key in a `configBySource` map (`preLogin`, `gui`, `amber`, `userSystem`) instead of spreading them all into one flat object, so the origin of every config value is preserved (addresses the review comment on #5545). - Derived the existing flat `env` getter from `configBySource` on read, keeping its shape identical so the ~50 existing `guiConfigService.env.<flag>` call sites are untouched. - Memoized `env` so it returns a stable object reference (rebuilt only when a source loads). This preserves the two-way `[(ngModel)]` write-through at `menu.component.html`, which only persists when the same object identity is held across change-detection reads. - Added a `source(name)` accessor that returns one endpoint's payload in isolation. - Added unit tests for: source isolation (each source retrievable alone, no key bleed across sources, empty object for an unloaded source), env reference stability (stable across reads, rebuilt after a new source loads, in-memory write discarded on reload), and merge precedence (later source wins on a shared key). ### Any related issues, documentation, discussions? Closes: #5669 ### How was this PR tested? - Run `cd frontend && npx ng test --include="src/app/common/service/gui-config.service.spec.ts"` and expect 15 passed (the 8 existing merge/orchestration tests plus 7 new tests covering source isolation, env reference stability, and merge precedence). - Confirm the merged view is unchanged: `service.env.defaultDataTransferBatchSize` still resolves after post-login load, while `service.source("amber")` returns only the amber payload and `service.source("gui")` does not contain `defaultDataTransferBatchSize`. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.8 in compliance with ASF --- .../app/common/service/gui-config.service.spec.ts | 115 +++++++++++++++++++++ .../src/app/common/service/gui-config.service.ts | 56 ++++++++-- 2 files changed, 160 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/common/service/gui-config.service.spec.ts b/frontend/src/app/common/service/gui-config.service.spec.ts index 92a0d5a615..9dad79e471 100644 --- a/frontend/src/app/common/service/gui-config.service.spec.ts +++ b/frontend/src/app/common/service/gui-config.service.spec.ts @@ -207,4 +207,119 @@ describe("GuiConfigService", () => { http.expectNone(`${API}/config/user-system`); await expect(pending).rejects.toThrow(/pre-login configuration/); }); + + // ─── source() isolation ─────────────────────────────────────────────────── + + it("source() keeps each endpoint's payload retrievable in isolation", async () => { + const preLoginPending = firstValueFrom(service.loadPreLogin()); + http.expectOne(`${API}/config/pre-login`).flush(PRE_LOGIN_PAYLOAD); + await preLoginPending; + + const postLoginPending = firstValueFrom(service.loadPostLogin()); + http.expectOne(`${API}/config/gui`).flush(GUI_PAYLOAD); + http.expectOne(`${API}/config/amber`).flush(AMBER_PAYLOAD); + http.expectOne(`${API}/config/user-system`).flush(USER_SYSTEM_PAYLOAD); + await postLoginPending; + + expect(service.source("preLogin")).toEqual(PRE_LOGIN_PAYLOAD); + expect(service.source("gui")).toEqual(GUI_PAYLOAD); + expect(service.source("amber")).toEqual(AMBER_PAYLOAD); + expect(service.source("userSystem")).toEqual(USER_SYSTEM_PAYLOAD); + }); + + it("source() does not bleed keys across sources", async () => { + // A value from one endpoint must not appear under another. + const pending = firstValueFrom(service.loadPostLogin()); + http.expectOne(`${API}/config/gui`).flush(GUI_PAYLOAD); + http.expectOne(`${API}/config/amber`).flush(AMBER_PAYLOAD); + http.expectOne(`${API}/config/user-system`).flush(USER_SYSTEM_PAYLOAD); + await pending; + + expect(service.source("amber")).toHaveProperty("defaultDataTransferBatchSize"); + expect(service.source("gui")).not.toHaveProperty("defaultDataTransferBatchSize"); + expect(service.source("gui")).not.toHaveProperty("inviteOnly"); + // env still flattens them together for the common single-flag call sites. + expect(service.env.defaultDataTransferBatchSize).toBe(400); + }); + + it("source() returns an empty object for an endpoint that has not loaded", () => { + expect(service.source("amber")).toEqual({}); + }); + + // ─── env reference stability ─────────────────────────────────────────────── + + it("env returns a stable reference between loads so two-way bindings persist", async () => { + const pending = firstValueFrom(service.loadPreLogin()); + http.expectOne(`${API}/config/pre-login`).flush(PRE_LOGIN_PAYLOAD); + await pending; + + // Repeated reads must return the same object (relied on by change detection + // and by the [(ngModel)] write-through at menu.component.html). + expect(service.env).toBe(service.env); + + // A write through env persists across subsequent reads. + (service.env as any).workflowEmailNotificationEnabled = true; + expect(service.env.workflowEmailNotificationEnabled).toBe(true); + }); + + it("env reference is rebuilt after a new source loads", async () => { + const preLoginPending = firstValueFrom(service.loadPreLogin()); + http.expectOne(`${API}/config/pre-login`).flush(PRE_LOGIN_PAYLOAD); + await preLoginPending; + const before = service.env; + + const postLoginPending = firstValueFrom(service.loadPostLogin()); + http.expectOne(`${API}/config/gui`).flush(GUI_PAYLOAD); + http.expectOne(`${API}/config/amber`).flush(AMBER_PAYLOAD); + http.expectOne(`${API}/config/user-system`).flush(USER_SYSTEM_PAYLOAD); + await postLoginPending; + + expect(service.env).not.toBe(before); + expect(service.env.defaultDataTransferBatchSize).toBe(400); + }); + + it("a write through env is discarded when a source reloads", async () => { + // Documents the boundary of the memoized write-through: in-memory edits made + // via [(ngModel)] land on the merged cache only, not on any source payload. + // A subsequent source load invalidates the cache, so the edit does not + // survive a re-fetch (e.g. a fresh login re-running loadPostLogin). This is + // intentional (config reloads are authoritative) and is locked in here so a + // future "persist edits across reloads" change is a deliberate decision. + const preLoginPending = firstValueFrom(service.loadPreLogin()); + http.expectOne(`${API}/config/pre-login`).flush(PRE_LOGIN_PAYLOAD); + await preLoginPending; + + (service.env as any).workflowEmailNotificationEnabled = true; + expect(service.env.workflowEmailNotificationEnabled).toBe(true); + + const postLoginPending = firstValueFrom(service.loadPostLogin()); + http.expectOne(`${API}/config/gui`).flush(GUI_PAYLOAD); + http.expectOne(`${API}/config/amber`).flush(AMBER_PAYLOAD); + http.expectOne(`${API}/config/user-system`).flush(USER_SYSTEM_PAYLOAD); + await postLoginPending; + + expect(service.env.workflowEmailNotificationEnabled).toBeUndefined(); + }); + + // ─── merge precedence ─────────────────────────────────────────────────────── + + it("env merges sources in MERGE_ORDER so a later source wins on a shared key", async () => { + // The typed source payloads are disjoint, so precedence never fires through + // the public API, but the merge reduce implements "later source wins" and + // nothing else covers it. Inject an overlapping key via the raw payloads to + // lock the documented order (preLogin, gui, amber, userSystem). + const preLoginPending = firstValueFrom(service.loadPreLogin()); + http.expectOne(`${API}/config/pre-login`).flush({ ...PRE_LOGIN_PAYLOAD, inviteOnly: false }); + await preLoginPending; + // userSystem comes after preLogin in MERGE_ORDER, so its value must win. + expect(service.env.inviteOnly).toBe(false); + + const postLoginPending = firstValueFrom(service.loadPostLogin()); + http.expectOne(`${API}/config/gui`).flush(GUI_PAYLOAD); + http.expectOne(`${API}/config/amber`).flush(AMBER_PAYLOAD); + http.expectOne(`${API}/config/user-system`).flush({ inviteOnly: true }); + await postLoginPending; + + expect(service.env.inviteOnly).toBe(true); + }); }); diff --git a/frontend/src/app/common/service/gui-config.service.ts b/frontend/src/app/common/service/gui-config.service.ts index 28695f7d06..7806f1a680 100644 --- a/frontend/src/app/common/service/gui-config.service.ts +++ b/frontend/src/app/common/service/gui-config.service.ts @@ -34,23 +34,36 @@ type AmberConfig = Pick<GuiConfig, "defaultDataTransferBatchSize">; type GuiOnlyConfig = Omit<GuiConfig, keyof PreLoginConfig | keyof AmberConfig | "inviteOnly">; type UserSystemConfig = Pick<GuiConfig, "inviteOnly">; +// One entry per backend config endpoint. +export type ConfigSource = "preLogin" | "gui" | "amber" | "userSystem"; + @Injectable({ providedIn: "root" }) export class GuiConfigService { - private config: Partial<GuiConfig> = {}; + // Each endpoint's payload, kept under its own key. + private configBySource: Partial<Record<ConfigSource, Partial<GuiConfig>>> = {}; + + // Memoized flat merge. Rebuilt only when a source is written so that env + // returns a stable reference: callers read env on every change-detection + // cycle, and one call site mutates env through a two-way [(ngModel)] binding, + // which only persists when the object identity is held across reads. + private mergedCache: GuiConfig | null = null; + + // Merge precedence when a key appears in multiple sources (later wins). + private static readonly MERGE_ORDER: ConfigSource[] = ["preLogin", "gui", "amber", "userSystem"]; constructor(private http: HttpClient) {} /** * APP_INITIALIZER entry point. Always loads /config/pre-login (anonymous). If * a JWT is already in localStorage (browser reload while logged in), chains - * /config/gui + /config/user-system in the same await so the full config is - * available before any post-login component mounts. + * /config/gui + /config/amber + /config/user-system in the same await so the + * full config is available before any post-login component mounts. */ load(): Observable<Partial<GuiConfig>> { return this.loadPreLogin().pipe( switchMap(() => { if (!GuiConfigService.hasStoredAccessToken()) { - return of(this.config); + return of(this.env); } return this.loadPostLogin().pipe( // Expired or malformed token → /config/gui returns 403. Continue @@ -58,7 +71,7 @@ export class GuiConfigService { // expiry on its own, so we shouldn't block bootstrap on it. catchError((err: unknown) => { console.warn("Failed to load authenticated config; continuing with pre-login only.", err); - return of(this.config); + return of(this.env); }) ); }) @@ -68,9 +81,9 @@ export class GuiConfigService { loadPreLogin(): Observable<Partial<GuiConfig>> { return this.http.get<PreLoginConfig>(`${AppSettings.getApiEndpoint()}/config/pre-login`).pipe( tap(preLogin => { - this.config = { ...this.config, ...preLogin }; + this.setSource("preLogin", preLogin); }), - map(() => this.config), + map(() => this.env), catchError((error: unknown) => { console.error("Failed to load pre-login configuration:", error); return throwError(() => new Error(`Failed to load pre-login configuration from backend: ${error}`)); @@ -80,7 +93,7 @@ export class GuiConfigService { /** * Fetches the authenticated portion of the configuration. Runs after the - * frontend has a valid JWT — called from APP_INITIALIZER on bootstrap when a + * frontend has a valid JWT, called from APP_INITIALIZER on bootstrap when a * stored token exists, and from UserService.handleAccessToken on fresh login. */ loadPostLogin(): Observable<Partial<GuiConfig>> { @@ -89,14 +102,35 @@ export class GuiConfigService { const userSystemConfig$ = this.http.get<UserSystemConfig>(`${AppSettings.getApiEndpoint()}/config/user-system`); return forkJoin([guiConfig$, amberConfig$, userSystemConfig$]).pipe( tap(([guiConfig, amberConfig, userSystemConfig]) => { - this.config = { ...this.config, ...guiConfig, ...amberConfig, ...userSystemConfig }; + this.setSource("gui", guiConfig); + this.setSource("amber", amberConfig); + this.setSource("userSystem", userSystemConfig); }), - map(() => this.config) + map(() => this.env) ); } + // Flat merge of all sources, memoized so reads return a stable reference. get env(): GuiConfig { - return this.config as GuiConfig; + if (this.mergedCache === null) { + this.mergedCache = GuiConfigService.MERGE_ORDER.reduce( + (merged, source) => ({ ...merged, ...this.configBySource[source] }), + {} as Partial<GuiConfig> + ) as GuiConfig; + } + return this.mergedCache; + } + + // One endpoint's payload, in isolation. Returns a shallow copy so callers + // cannot mutate the stored source and desync it from the memoized env view. + source(name: ConfigSource): Partial<GuiConfig> { + return { ...(this.configBySource[name] ?? {}) }; + } + + // Store a source payload and invalidate the merged view. + private setSource(name: ConfigSource, payload: Partial<GuiConfig>): void { + this.configBySource[name] = payload; + this.mergedCache = null; } private static hasStoredAccessToken(): boolean {
