From 2148ac96a8eb55179cd6d7b4ad492f21bc556dc3 Mon Sep 17 00:00:00 2001
From: Tahmid Ahmed
Date: Tue, 10 Nov 2026 01:56:24 -0500
Subject: [PATCH 1/3] DINO-839: support loading ui feature flags via backends
app props (java backend will parse index.html and load a config json object
into it). these feature flags can be changed at runtime. school and tag ui
feature flags have been removed (they are no longer in a state where they can
be turned off).
---
.github/scripts/build-image/index.ts | 11 +--
infra/Dockerfile | 2 -
js/README.md | 29 +++++++-
js/index.html | 3 +
js/runtime-config.ts | 70 +++++++++++++++++++
js/src/app/_component/MiniLeaderboard.tsx | 5 +-
.../app/_component/MiniLeaderboardMobile.tsx | 3 +-
.../DashboardLeaderboard.tsx | 12 ++--
.../DashboardLeaderboard/MyCurrentPoints.tsx | 5 +-
.../leaderboard/_components/Leaderboard.tsx | 55 +++++++--------
js/src/app/user/[userId]/UserProfile.page.tsx | 3 +-
.../app/user/all/_components/UserSearch.tsx | 6 +-
js/src/components/ui/LeaderboardCard.tsx | 3 +-
js/src/components/ui/auth/AvatarButton.tsx | 21 +++---
js/src/components/ui/tags/TagList.tsx | 3 -
js/src/lib/config/index.ts | 19 +++++
js/src/lib/ff/duel/index.ts | 5 +-
js/src/lib/ff/index.ts | 5 +-
js/src/lib/ff/metrics/index.ts | 3 +
js/src/lib/ff/school/index.ts | 2 -
js/src/lib/ff/tag/index.ts | 2 -
js/src/lib/router.tsx | 18 ++---
js/src/lib/test/defaults.tsx | 14 ++++
js/tsconfig.node.json | 2 +-
js/vite.config.ts | 9 ++-
.../codebloom/common/ff/FFAspect.java | 2 +-
.../common/ff/FeatureFlagManager.java | 26 +++++--
.../properties/FeatureFlagConfiguration.java | 5 ++
.../codebloom/shared/tag/TagMetadataList.java | 2 +-
.../shared/topic/TopicMetadataList.java | 2 +-
.../utilities/ApplicationShellController.java | 47 +++++++++++++
.../complex/ComplexJSTypesGenerator.java | 10 ++-
src/main/resources/application-ci.yaml | 7 ++
src/main/resources/application-dev.yaml | 7 ++
src/main/resources/application.yml | 6 ++
.../common/ff/FeatureFlagManagerTest.java | 4 +-
.../ApplicationShellControllerTest.java | 54 ++++++++++++++
37 files changed, 361 insertions(+), 121 deletions(-)
create mode 100644 js/runtime-config.ts
create mode 100644 js/src/lib/config/index.ts
create mode 100644 js/src/lib/ff/metrics/index.ts
delete mode 100644 js/src/lib/ff/school/index.ts
delete mode 100644 js/src/lib/ff/tag/index.ts
create mode 100644 src/main/java/org/patinanetwork/codebloom/utilities/ApplicationShellController.java
create mode 100644 src/test/java/org/patinanetwork/codebloom/utilities/ApplicationShellControllerTest.java
diff --git a/.github/scripts/build-image/index.ts b/.github/scripts/build-image/index.ts
index ab86ce79f..6269daff4 100644
--- a/.github/scripts/build-image/index.ts
+++ b/.github/scripts/build-image/index.ts
@@ -115,16 +115,7 @@ async function main() {
console.log("Building image with following tags:");
tags.forEach((tag) => console.log(tag));
- const buildArgs = {
- ...(type === "web" ? { COMMIT_SHA: gitSha } : {}),
- ...(environment === "staging" &&
- type !== "leetcode-auth-stealer-service" &&
- type !== "leetcode-auth-service"
- ? {
- VITE_STAGING: true,
- }
- : {}),
- };
+ const buildArgs: Record = type === "web" ? { COMMIT_SHA: gitSha } : {};
const originalCwd = process.cwd();
try {
diff --git a/infra/Dockerfile b/infra/Dockerfile
index c2b536d86..c742e99a6 100644
--- a/infra/Dockerfile
+++ b/infra/Dockerfile
@@ -12,8 +12,6 @@ RUN corepack enable pnpm && corepack use $(node -p "require('./package.json').pa
COPY js/ ./
-ARG VITE_STAGING
-ENV VITE_STAGING=${VITE_STAGING}
RUN pnpm run build
FROM mirror.gcr.io/library/node:22 AS email-build
diff --git a/js/README.md b/js/README.md
index c66e440da..daae8ab7f 100644
--- a/js/README.md
+++ b/js/README.md
@@ -31,7 +31,8 @@ js/
│ │ │ ├── schema # input validation schemas used in the frontend
│ │ │ ├── types # autogenerated types from OpenAPI schema
│ │ │ └── utils
-│ │ ├── ff # feature flags
+│ │ ├── config # backend-rendered runtime config (#codebloom-config)
+│ │ ├── ff # feature flags (read from runtime config)
│ │ ├── helper # stateless helper functions
│ │ ├── hooks # custom re-usable react hooks
│ │ ├── queryProvider.tsx # ReactQueryProvider
@@ -125,6 +126,32 @@ which allows us to define a complex relationship of parent tags to child tags. T
+### Feature flags
+
+UI feature flags live in the backend config under `ff.ui` and are handed to the frontend in `index.html` at page load, so [`src/lib/ff`](./src/lib/ff/) exposes them as plain booleans: no request, no loading state. They are separate from the backend `ff.*` flags enforced by `@FF`; a UI flag only controls visibility.
+
+Changing a value takes a backend restart and a page reload, not a frontend rebuild.
+
+#### Adding a UI flag
+
+1. Add the key under `ff.ui` in [`application.yml`](../src/main/resources/application.yml), and override it in the profile files (`application-dev.yaml`, `application-ci.yaml`) as needed. No Java changes are required.
+ ```yaml
+ ff:
+ ui:
+ myFeature: false
+ ```
+ > [!IMPORTANT]
+ > The key reaches the frontend exactly as written, so use the camelCase name the frontend reads. Set it in YAML, not via an environment variable: env var names are uppercase and would arrive as a different key.
+2. Regenerate types by running the backend with the `dev` profile (or `just type-gen`). This adds the key to `UiFeatureFlags` in `complex.ts`.
+3. Export it from `src/lib/ff`:
+ ```ts
+ export const myFeatureFF = runtimeConfig.featureFlags.myFeature;
+ ```
+4. If the feature has backend endpoints, gate them with `@FF` as well.
+
+> [!NOTE]
+> `pnpm run dev` needs the backend running (`CODEBLOOM_BACKEND_URL`, default `http://localhost:8080`) to supply the flags; without it Vite returns an error rather than guessing values.
+
### ApiURL
You can view the implementation of `ApiURL` [here](./src/lib/api/common/apiURL.ts).
diff --git a/js/index.html b/js/index.html
index e29af8ec1..1e824dbff 100644
--- a/js/index.html
+++ b/js/index.html
@@ -16,6 +16,9 @@
/>
+
diff --git a/js/runtime-config.ts b/js/runtime-config.ts
new file mode 100644
index 000000000..8098f0f3e
--- /dev/null
+++ b/js/runtime-config.ts
@@ -0,0 +1,70 @@
+import type { Plugin } from "vite";
+
+import { readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+
+const placeholder = "__CODEBLOOM_CONFIG__";
+
+/**
+ * Create codebloom-config by calling the Java backend to get fully transformed index.html
+ * */
+export function runtimeConfigPlugin(backendUrl: string): Plugin {
+ async function inject(html: string): Promise {
+ const response = await fetch(new URL("/index.html", backendUrl), {
+ signal: AbortSignal.timeout(5000),
+ });
+ if (!response.ok) {
+ throw new Error(`Backend HTML bootstrap failed: HTTP ${response.status}`);
+ }
+
+ const backendHtml = await response.text();
+ const config = backendHtml.match(
+ /";
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ private static FeatureFlagManager flags() {
+ FeatureFlagConfiguration ff = new FeatureFlagConfiguration();
+ ff.setDuels(true);
+ ff.setUserMetrics(true);
+ ff.getUi().putAll(Map.of("duels", false, "userMetrics", false, "notInJava", true));
+ return new FeatureFlagManager(ff);
+ }
+
+ private static ByteArrayResource html(final String body) {
+ return new ByteArrayResource(body.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private String render(final String template) throws Exception {
+ var controller = new ApplicationShellController(flags(), objectMapper, html(template));
+ return new String(controller.index().getBody(), StandardCharsets.UTF_8);
+ }
+
+ @Test
+ void passesUiFlagsThroughIndependentlyOfBackendFlags() throws Exception {
+ String body = render(OPEN + "__CODEBLOOM_CONFIG__" + CLOSE);
+ String json = body.substring(OPEN.length(), body.length() - CLOSE.length());
+
+ // Backend duels/userMetrics are on; the browser sees only ff.ui, including keys Java never declares.
+ assertEquals(
+ objectMapper.readTree("{\"featureFlags\":{\"duels\":false,\"userMetrics\":false,\"notInJava\":true}}"),
+ objectMapper.readTree(json));
+ }
+
+ @Test
+ void refusesTemplateWithoutConfigPlaceholder() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> new ApplicationShellController(flags(), objectMapper, html("")));
+ }
+}
From 9ff3f59866357800e0f41a60a5e4cec90bb32925 Mon Sep 17 00:00:00 2001
From: Tahmid Ahmed
Date: Tue, 10 Nov 2026 02:11:13 -0500
Subject: [PATCH 2/3] DINO-839: fix broken test
---
.../leetcode/RefetchIncompleteQuestionsServiceTest.java | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/test/java/org/patinanetwork/codebloom/scheduled/leetcode/RefetchIncompleteQuestionsServiceTest.java b/src/test/java/org/patinanetwork/codebloom/scheduled/leetcode/RefetchIncompleteQuestionsServiceTest.java
index 8d6864dbd..63f7902c8 100644
--- a/src/test/java/org/patinanetwork/codebloom/scheduled/leetcode/RefetchIncompleteQuestionsServiceTest.java
+++ b/src/test/java/org/patinanetwork/codebloom/scheduled/leetcode/RefetchIncompleteQuestionsServiceTest.java
@@ -26,7 +26,9 @@
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
-@SpringBootTest
+// "test" profile doesn't set codebloom.frontend.index, so point the shell at the source template.
+// TODO: fix this in the future
+@SpringBootTest(properties = "codebloom.frontend.index=file:js/index.html")
@ActiveProfiles("test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
From 8cea7a070c0e7137c6c5e7ab3b7a798f860f7788 Mon Sep 17 00:00:00 2001
From: Tahmid Ahmed
Date: Tue, 10 Nov 2026 02:28:32 -0500
Subject: [PATCH 3/3] DINO-839: add tests to pass test coverage
---
exclusions.ts | 1 +
js/src/__mock__/leaderboard/index.ts | 52 +++++++++++++++++++
.../DashboardLeaderboard.test.tsx | 13 +++++
.../_components/Leaderboard.test.tsx | 13 +++++
js/src/lib/config/index.test.ts | 47 +++++++++++++++++
5 files changed, 126 insertions(+)
create mode 100644 js/src/lib/config/index.test.ts
diff --git a/exclusions.ts b/exclusions.ts
index 830f73e64..cd4a64785 100644
--- a/exclusions.ts
+++ b/exclusions.ts
@@ -14,6 +14,7 @@ export const backendExclusions = [
export const frontendExclusions = [
`${frontendBaseDir}/**/*.test.ts`,
`${frontendBaseDir}/**/*.test.tsx`,
+ `${frontendBaseDir}/lib/test/**`,
`${frontendBaseDir}/lib/api/queries/**/*.ts`,
`${frontendBaseDir}/lib/api/schema/**/*.ts`,
];
diff --git a/js/src/__mock__/leaderboard/index.ts b/js/src/__mock__/leaderboard/index.ts
index 5722b289a..0fb8cd6d0 100644
--- a/js/src/__mock__/leaderboard/index.ts
+++ b/js/src/__mock__/leaderboard/index.ts
@@ -1,4 +1,8 @@
+import type { UnknownApiResponse } from "@/lib/api/common/apiResponse";
+import type { Api } from "@/lib/api/types";
+
import { ApiURL } from "@/lib/api/common/apiURL";
+import { Tag } from "@/lib/api/types/schema";
import d from "dayjs";
import { http, HttpResponse } from "msw";
import { v4 as uuid } from "uuid";
@@ -185,6 +189,54 @@ export const currentLeaderboardUsersHandler = http.get(
},
);
+const mockLeaderboardUser = (
+ index: number,
+ name: string,
+ nickname: string | null,
+ tags: Tag[],
+): Api<"IndexedUserWithScoreDto"> => ({
+ index,
+ id: `user-${index}`,
+ discordId: `discord-${index}`,
+ discordName: name,
+ leetcodeUsername: name,
+ nickname,
+ admin: false,
+ profileUrl: null,
+ tags: tags.map((tag) => ({
+ id: `user-${index}-${tag}`,
+ createdAt: "2026-01-01T00:00:00Z",
+ userId: `user-${index}`,
+ tag,
+ })),
+ achievements: [],
+ totalScore: 200 - index * 10,
+});
+
+/** Mixes nicknames and tags so verified-nickname and tag rendering paths are exercised. */
+export const currentLeaderboardUsersWithTagsHandler = http.get(
+ currentLeaderboardUsers.url.toString(),
+ () => {
+ return HttpResponse.json({
+ success: true,
+ message: "Leaderboard users loaded!",
+ payload: {
+ hasNextPage: false,
+ pages: 1,
+ pageSize: 20,
+ items: [
+ mockLeaderboardUser(1, "aphrodite", "Aphro", [Tag.Hunter]),
+ mockLeaderboardUser(2, "poseidon", null, [Tag.Nyu]),
+ mockLeaderboardUser(3, "hermes", null, []),
+ mockLeaderboardUser(4, "athena", "Athena", [Tag.Hunter]),
+ mockLeaderboardUser(5, "ares", null, [Tag.Nyu]),
+ mockLeaderboardUser(6, "apollo", "Apollo", []),
+ ],
+ },
+ } satisfies UnknownApiResponse>);
+ },
+);
+
export const currentLeaderboardUsersFailedHandler = http.get(
currentLeaderboardUsers.url.toString(),
() => {
diff --git a/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.test.tsx b/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.test.tsx
index aba0a5d96..3b764849d 100644
--- a/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.test.tsx
+++ b/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.test.tsx
@@ -2,6 +2,7 @@ import {
currentLeaderboardUsersErrorHandler,
currentLeaderboardUsersFailedHandler,
currentLeaderboardUsersHandler,
+ currentLeaderboardUsersWithTagsHandler,
successfulLeaderboardHandlers,
} from "@/__mock__/leaderboard";
import LeaderboardForDashboard from "@/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard";
@@ -157,4 +158,16 @@ describe("LeaderboardForDashboard", () => {
expect(screen.getByText("Me")).toBeInTheDocument();
});
});
+
+ it("should render tags beside nicknames or discord names", async () => {
+ server.use(currentLeaderboardUsersWithTagsHandler);
+ renderProviderFn?.(
+ ,
+ );
+
+ // aphrodite/athena: tag beside nickname; poseidon/ares: tag beside discord name.
+ expect(await screen.findByText("Aphro")).toBeInTheDocument();
+ expect(screen.getAllByAltText("Hunter College Logo")).toHaveLength(2);
+ expect(screen.getAllByAltText("NYU Logo")).toHaveLength(2);
+ });
});
diff --git a/js/src/app/leaderboard/_components/Leaderboard.test.tsx b/js/src/app/leaderboard/_components/Leaderboard.test.tsx
index 98bee3e06..512dfc7f1 100644
--- a/js/src/app/leaderboard/_components/Leaderboard.test.tsx
+++ b/js/src/app/leaderboard/_components/Leaderboard.test.tsx
@@ -1,5 +1,6 @@
import {
currentLeaderboardUsersHandler,
+ currentLeaderboardUsersWithTagsHandler,
successfulLeaderboardHandlers,
} from "@/__mock__/leaderboard";
import { CurrentLeaderboard } from "@/app/leaderboard/_components/Leaderboard";
@@ -294,4 +295,16 @@ describe("Leaderboard API Succeeded", () => {
});
}
});
+
+ it("should render nicknames and tags for podium and list entries", async () => {
+ server.use(currentLeaderboardUsersWithTagsHandler);
+ renderProviderFn?.();
+
+ // athena (list): nickname + tag, ares (list): tag only, apollo (list): nickname only.
+ expect(await screen.findByText("Athena")).toBeInTheDocument();
+ expect(screen.getByText("Apollo")).toBeInTheDocument();
+ // aphrodite (podium) + athena (list) carry Hunter; poseidon (podium) + ares (list) carry NYU.
+ expect(screen.getAllByAltText("Hunter College Logo")).toHaveLength(2);
+ expect(screen.getAllByAltText("NYU Logo")).toHaveLength(2);
+ });
});
diff --git a/js/src/lib/config/index.test.ts b/js/src/lib/config/index.test.ts
new file mode 100644
index 000000000..258541def
--- /dev/null
+++ b/js/src/lib/config/index.test.ts
@@ -0,0 +1,47 @@
+describe("runtimeConfig", () => {
+ let config: HTMLElement;
+ let original: string | null;
+
+ beforeEach(() => {
+ const el = document.getElementById("codebloom-config");
+ if (!el) {
+ throw new Error("Test setup must provide #codebloom-config");
+ }
+ config = el;
+ original = el.textContent;
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ config.textContent = original;
+ });
+
+ // `@/lib/config` reads the DOM once at import time, so each case needs a fresh
+ // module instance via dynamic import; a static import would only see the setup default.
+ it("reads feature flags from the embedded config", async () => {
+ config.textContent = JSON.stringify({
+ featureFlags: { duels: false, userMetrics: true },
+ });
+
+ const { runtimeConfig } = await import("@/lib/config");
+
+ expect(runtimeConfig.featureFlags).toEqual({
+ duels: false,
+ userMetrics: true,
+ });
+ });
+
+ it("fails fast when the embedded config is missing", async () => {
+ config.textContent = "";
+
+ await expect(import("@/lib/config")).rejects.toThrow(
+ "Missing #codebloom-config runtime configuration",
+ );
+ });
+
+ it("rejects config with non-boolean flag values", async () => {
+ config.textContent = JSON.stringify({ featureFlags: { duels: "yes" } });
+
+ await expect(import("@/lib/config")).rejects.toThrow();
+ });
+});