Repository navigation
perf(webview): load locales on demand instead of eager-globbing all 18 #2267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
myk1yt
wants to merge
6
commits into
Zoo-Code-Org:main
Choose a base branch
from
myk1yt:perf/lazy-locales
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
cb5ef06
perf(webview): load locales on demand instead of eager-globbing all 18
myk1yt 3892eca
fix(webview): drop the orphaned loadTranslations export and cover the…
myk1yt 224db25
test(webview): cover the cancelled arm of the locale-switch guard
myk1yt 4fcf7ee
fix(webview): type locale resources and correct the switch comment
myk1yt fee8599
test(webview): assert the English first paint for a non-English initi…
09d0180
test(webview): pin the warm locale switch to the previous language
myk1yt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
150 changes: 150 additions & 0 deletions
150
webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { act, render, screen, waitFor } from "@testing-library/react" | ||
| import React from "react" | ||
|
|
||
| import i18next, { loadLanguage } from "../setup" | ||
| import TranslationProvider, { useAppTranslation } from "../TranslationContext" | ||
|
|
||
| // The provider reads the active language from extension state. Tests drive it | ||
| // through a mutable holder so a rerender can simulate a settings change. | ||
| const extensionState: { language: string } = { language: "en" } | ||
|
|
||
| vi.mock("@/context/ExtensionStateContext", () => ({ | ||
| useExtensionState: () => extensionState, | ||
| })) | ||
|
|
||
| const Probe = () => { | ||
| const { t, i18n } = useAppTranslation() | ||
|
|
||
| return ( | ||
| <div> | ||
| <span data-testid="error-title">{t("common:errorBoundary.title")}</span> | ||
| <span data-testid="active-language">{i18n.language}</span> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| const renderProbe = (language: string) => { | ||
| extensionState.language = language | ||
|
|
||
| return render( | ||
| <TranslationProvider> | ||
| <Probe /> | ||
| </TranslationProvider>, | ||
| ) | ||
| } | ||
|
|
||
| describe("TranslationProvider locale loading", () => { | ||
| beforeEach(async () => { | ||
| // i18next is a module-level singleton, so a language left active by an | ||
| // earlier test would mask the fallback path this suite is checking. | ||
| await i18next.changeLanguage("en") | ||
| }) | ||
|
|
||
| it("renders translated strings for a non-English initial language", async () => { | ||
| renderProbe("ko") | ||
|
|
||
| // Korean bundles are not in the entry chunk, so the first paint resolves | ||
| // through the English fallback and the switch lands after the fetch. | ||
| expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByTestId("error-title")).toHaveTextContent("문제가 발생했습니다") | ||
| }) | ||
| expect(screen.getByTestId("active-language")).toHaveTextContent("ko") | ||
| }) | ||
|
|
||
| it("loads a non-eager locale when the language changes at runtime", async () => { | ||
| const { rerender } = renderProbe("en") | ||
|
|
||
| expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") | ||
|
|
||
| extensionState.language = "ja" | ||
| await act(async () => { | ||
| rerender( | ||
| <TranslationProvider> | ||
| <Probe /> | ||
| </TranslationProvider>, | ||
| ) | ||
| }) | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByTestId("error-title")).toHaveTextContent("問題が発生しました") | ||
| }) | ||
| expect(screen.getByTestId("active-language")).toHaveTextContent("ja") | ||
| }) | ||
|
|
||
| it("falls back to English when the requested locale has no resources", async () => { | ||
| renderProbe("xx-unsupported") | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") | ||
| }) | ||
| }) | ||
|
|
||
| it("keeps English available after switching away and back", async () => { | ||
| const { rerender } = renderProbe("de") | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen") | ||
| }) | ||
|
|
||
| extensionState.language = "en" | ||
| await act(async () => { | ||
| rerender( | ||
| <TranslationProvider> | ||
| <Probe /> | ||
| </TranslationProvider>, | ||
| ) | ||
| }) | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") | ||
| }) | ||
| }) | ||
|
|
||
| it("keeps the previous locale rendered while a warm switch is still loading", async () => { | ||
| const { rerender } = renderProbe("de") | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen") | ||
| }) | ||
|
|
||
| extensionState.language = "fr" | ||
| await act(async () => { | ||
| rerender( | ||
| <TranslationProvider> | ||
| <Probe /> | ||
| </TranslationProvider>, | ||
| ) | ||
| }) | ||
|
|
||
| // French is not in the entry chunk and no other case loads it, so the | ||
| // switch cannot have landed yet. A `changeLanguage` fired before the fetch | ||
| // resolves would paint the English fallback here instead of German. | ||
| expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen") | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByTestId("error-title")).toHaveTextContent("Une erreur s'est produite") | ||
| }) | ||
| expect(screen.getByTestId("active-language")).toHaveTextContent("fr") | ||
| }) | ||
|
|
||
| it("skips the language switch when the provider unmounts before the fetch resolves", async () => { | ||
| const changeLanguage = vi.spyOn(i18next, "changeLanguage") | ||
|
|
||
| const { unmount } = renderProbe("vi") | ||
| // The locale chunk is still in flight here, so the effect cleanup runs | ||
| // before the fetch settles and `cancelled` decides the outcome. | ||
| unmount() | ||
|
|
||
| // `loadLanguage` memoizes the in-flight promise, so awaiting the same | ||
| // locale here waits for the exact resolution the guard is deciding on. | ||
| // The provider attached its `.then` first, so its callback has already run | ||
| // by the time this returns: the switch was suppressed, not left pending. | ||
| await loadLanguage("vi") | ||
|
|
||
| expect(i18next.hasResourceBundle("vi", "common")).toBe(true) | ||
| expect(changeLanguage).not.toHaveBeenCalledWith("vi") | ||
| expect(i18next.language).not.toBe("vi") | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "../setup" | ||
|
|
||
| // A dynamic import that rejects is what a 404 or a chunk missing from the | ||
| // deployed manifest looks like at runtime. One namespace is enough: `loadLanguage` | ||
| // registers a language only after every namespace resolves. | ||
| vi.mock("../locales/ja/common.json", () => { | ||
| throw new Error("Failed to fetch dynamically imported module") | ||
| }) | ||
|
|
||
| describe("i18n setup locale load failure", () => { | ||
| afterEach(() => { | ||
| vi.restoreAllMocks() | ||
| }) | ||
|
|
||
| it("resolves without throwing and keeps the English fallback", async () => { | ||
| const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) | ||
|
|
||
| await expect(loadLanguage("ja")).resolves.toBeUndefined() | ||
|
|
||
| expect(warn).toHaveBeenCalledWith("Could not load ja translations:", expect.any(Error)) | ||
| // All-or-nothing per language: the namespaces that did resolve are not | ||
| // registered either, so no half-translated locale can be activated. | ||
| expect(i18next.hasResourceBundle("ja", "common")).toBe(false) | ||
| expect(i18next.hasResourceBundle("ja", "settings")).toBe(false) | ||
| expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("Something went wrong") | ||
| }) | ||
|
|
||
| it("does not memoize a failed load, so the next call re-attempts it", async () => { | ||
| const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) | ||
|
|
||
| await loadLanguage("ja") | ||
| await loadLanguage("ja") | ||
|
|
||
| // `loadedLanguages` is only updated on success and `pendingLanguages` is | ||
| // cleared in `finally`, so a transient failure must not be memoized. | ||
| // This covers `loadLanguage`'s own bookkeeping only: engines that cache a | ||
| // failed module fetch in the module map (every Chromium below 156, so the | ||
| // VS Code webview) can still reject the second import from that cache | ||
| // without re-requesting the chunk. | ||
| expect(warn).toHaveBeenCalledTimes(2) | ||
| expect(i18next.language).toBe(FALLBACK_LANGUAGE) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "../setup" | ||
|
|
||
| describe("i18n setup locale loading", () => { | ||
| it("ships the fallback locale eagerly", () => { | ||
| expect(i18next.hasResourceBundle(FALLBACK_LANGUAGE, "common")).toBe(true) | ||
| expect(i18next.hasResourceBundle(FALLBACK_LANGUAGE, "settings")).toBe(true) | ||
| }) | ||
|
|
||
| it("does not ship non-fallback locales in the entry graph", () => { | ||
| // `hasResourceBundle` is false until `loadLanguage` fetches a chunk, which | ||
| // is what keeps the 17 other locales out of the first paint. | ||
| for (const language of ["ko", "ja", "de", "zh-CN"]) { | ||
| expect(i18next.hasResourceBundle(language, "common")).toBe(false) | ||
| } | ||
| }) | ||
|
|
||
| it("registers every namespace of a lazily loaded locale", async () => { | ||
| await loadLanguage("ko") | ||
|
|
||
| expect(i18next.hasResourceBundle("ko", "common")).toBe(true) | ||
| expect(i18next.hasResourceBundle("ko", "settings")).toBe(true) | ||
| expect(i18next.t("common:errorBoundary.title", { lng: "ko" })).toBe("문제가 발생했습니다") | ||
| }) | ||
|
|
||
| it("shares one fetch across concurrent requests for the same locale", async () => { | ||
| const addResourceBundle = vi.spyOn(i18next, "addResourceBundle") | ||
|
|
||
| // `loadLanguage` memoizes the in-flight promise, so concurrent callers get | ||
| // the very same promise back: one fetch, one registration pass. | ||
| const inFlight = [loadLanguage("ja"), loadLanguage("ja"), loadLanguage("ja")] | ||
| expect(inFlight[1]).toBe(inFlight[0]) | ||
| expect(inFlight[2]).toBe(inFlight[0]) | ||
|
|
||
| const results = await Promise.all(inFlight) | ||
|
|
||
| expect(results).toHaveLength(3) | ||
| // 9 namespaces registered once each, not 27. | ||
| expect(addResourceBundle).toHaveBeenCalledTimes(9) | ||
| expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("問題が発生しました") | ||
| }) | ||
|
|
||
| it("resolves without throwing for a locale that has no bundles", async () => { | ||
| await expect(loadLanguage("xx-unsupported")).resolves.toBeUndefined() | ||
| expect(i18next.t("common:errorBoundary.title", { lng: "xx-unsupported" })).toBe("Something went wrong") | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.