Fall back to English when ProjectSelector's localized strings are unresolved - #2829
jolierabideau wants to merge 4 commits into
Conversation
katherinejensen00
left a comment
There was a problem hiding this comment.
PR #2829 review — Fall back to English when ProjectSelector's localized strings are unresolved
PR: #2829
Author: @jolierabideau · Branch: fix/project-selector-unresolved-localization-keys
Reviewed at: 7b60770eace against merge-base e7d19304ad7 (lib/platform-bible-react/dist/ skipped; I checked that it matches src)
CI at review time: CodeQL and Analyze passed; the three Build jobs were still pending.
How this was reviewed: separate passes for correctness and tests, architecture, and comments and docs. Then an adversarial pass tried to knock those findings down, and a /code-review max pass (10 finder angles) ran on top. Every finding below was checked against the PR's version of the file. The adversarial pass dropped findings that were wrong, a matter of taste, or repeats. Line numbers are in the PR's version of each file, and every comment is anchored to a line the PR changes.
Summary table
| # | Severity | File:line to attach | Finding |
|---|---|---|---|
| 1 | Important | lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:214 |
Reuse the existing resolveLocalizedString instead of a new copy of the rule |
| 2 | Important | lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:216 |
The blank check removes the ariaLabel: '' opt-out and leaves || undefined at :1062 as dead code |
| 3 | Important | extensions/src/platform-scripture/src/find/find.component.test.tsx:66 |
Consumer wording is replaced with generic English that can be wrong ("Select a project" when none are open) |
| 4 | Minor | extensions/src/platform-scripture/src/checklist.web-view.tsx:831 |
ariaLabel skips firstUsableLabel, so both checklist pickers get the same accessible name |
| 5 | Minor | lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:216 |
resolveStrings now splits every field into graphemes on every render, and it isn't memoized |
| 6 | Minor | lib/platform-bible-react/src/components/advanced/project-selector/project-selector.groupings.ts:87 |
Two different definitions of "unresolved", and grouping labels only go through the weaker one |
| 7 | Minor | extensions/src/platform-scripture/src/checklist.web-view.tsx:964 |
New primaryProjectLabel fallback can show a GUID, and the label changes once strings load |
| 8 | Minor | lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.test.tsx:587 |
New tests: one assertion can never fail, one test doesn't reach the branch it names, one key doesn't exist |
| 9 | Minor | lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:196 |
The same explanation appears at 6 new places; keep one, point to it from the rest |
| 10 | Minor | PR description | The #2796 warning is out of date, the fr/km/zh claim is wrong, and a pasted review summary buries the important notes |
| 11 | Nit | extensions/src/platform-scripture/src/checklist.web-view.tsx:965 |
New hard-coded English label duplicates localizedStrings.json |
| 12 | Nit | lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:214 |
Object.fromEntries round-trip loses field types (no risk today) |
| 13 | Nit | extensions/src/platform-scripture/src/checklist.web-view.test.tsx:109 |
Hoisted vi.mock factory uses a top-level import, against the rule stated at :124 |
| 14 | Nit | lib/platform-bible-react/src/components/advanced/project-selector/project-selector.groupings.ts:62 |
"So they cannot drift" is untrue; firstUsableLabel does its own trim() check |
| 15 | Nit | lib/platform-bible-react/src/components/advanced/project-selector/project-selector.groupings.test.ts:76 |
Comment names searchPlaceholder but the test uses clearAll; a checklist test comment sits above the wrong constant |
| 16 | Follow-up (not blocking) | extensions/src/platform-scripture/src/checklist.web-view.tsx:128 |
7 more localizedStrings[…] ?? fallback sites have the same bug, and Localization-Guide.md still teaches that pattern |
Comments for the PR owner
Each comment below is written so it can be pasted into the PR as is.
1. Important — reuse resolveLocalizedString
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:214
This package already has a tested helper for this exact problem, resolveLocalizedString in lib/platform-bible-react/src/utils/localization.util.ts:20, so this PR adds another copy of the rule instead of using it.
Details:
resolveLocalizedString(value, fallback)exists for this bug. Its doc saysuseLocalizedStringsseeds{ [key]: key }, so?? 'Default'never falls back. It has its own test file, andBookChapterControlandRecentSearchesalready use it (15 call sites).- After this PR, the "is this value usable?" rule exists in several places that don't quite agree:
resolveLocalizedString,readProjectSelectorString(project-selector.groupings.ts:87),resolveStrings(here),firstUsableLabel(checklist.web-view.tsx:128), and older ones in the renderer (localizedOrEnglish,createCrashedViewLocalizer). - It isn't a drop-in swap, and the differences matter:
resolveLocalizedStringchecks!value, so it catches''but not whitespace-only strings. This PR treats whitespace-only as unresolved on purpose.- Its key test is
/^%[^%]*%$/.isLocalizeKeyis looser (startsWith('%') && endsWith('%')): a bare'%'or'%d%'counts as a key. No shipped translation looks like that today. ButfirstUsableLabelalso runs this check onprimaryProjectName, which a user chooses, so a project named like%ABC%would be skipped.
- Suggestion: extend
resolveLocalizedStringto cover whitespace-only values, then writeresolveStringsas a typed loop overObject.keys(DEFAULT_STRINGS)that calls it for each field. That removes the third copy of the rule and also fixes #5 and #12. ThefirstUsableLabelcandidate chain can then sit on the same helper.
2. Important — ariaLabel: '' can no longer turn the label off
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:216
Passing ariaLabel: '' used to leave the trigger with no aria-label. The new blank check swaps '' for "Projects & resources", which quietly removes that option and leaves the code built to support it unreachable.
Details:
- The trigger renders
aria-label={strings.ariaLabel || undefined}at :1062.|| undefinedexists so that''means "no aria-label here; the visible text or a parent element names the control." - Now
isBlankLocalizedValuedrops''before it reaches :1062, sostrings.ariaLabelis never empty and|| undefinednever runs. - The sibling component in this library handles the same case the other way, on purpose:
recent-searches.component.tsx:95-97says "An empty string is a deliberate 'no label'…, so onlyundefinedand a raw localization key fall back", and only sends non-empty values throughresolveLocalizedString. - The new test at
project-selector.component.test.tsx:593(falls back to English for a blank localized value, withariaLabel: '') makes this change official. Anyone who later tries to restore the opt-out will see that test fail. - Suggestion: either keep the opt-out the way
recent-searchesdoes (ariaLabel === '' ? '' : …), or, if dropping it is intended, remove|| undefinedand say in the prop's TSDoc that''falls back.
3. Important — generic English replaces the consumer's own wording, and can say the wrong thing
Attach to: extensions/src/platform-scripture/src/find/find.component.test.tsx:66
When a consumer's own override is unresolved, the new check drops it and the picker shows the library's generic English. For Find, that turns "No open projects or resources" into "Select a project", which asks the user to pick a project at the very moment there are none open.
Details:
find.component.tsx:975-977setsbuttonPlaceholderto%webView_find_projectFilter_noOpenProjectsOrResources%("No open projects or resources").checks-side-panel.component.tsx:226-230does the same with its "No project" key. Before strings resolve, or after a platform error, both now showDEFAULT_STRINGS.buttonPlaceholder= "Select a project".- The PR fixed this for the checklist with
firstUsableLabel, but no other consumer got the same fix. The same thing happens in:- Manage Books dialog:
manage-books-dialog.component.tsx:619definest = (key, fallback) => localizedStrings[key] ?? fallback, and that??never runs. So the Copy "From" picker (:2351), the Create "Based on" picker (:2647), and the sidebar picker (manage-books-sidebar.component.tsx:310) lose "Select project" / "Select reference project" / "Project". All three end up announcing "Projects & resources". - Settings sidebar:
settings-tab.component.tsx:305passes a raw lookup forprojectsSidebarGroupLabel. Unresolved, the heading shows the raw%settings_sidebar_projectSettingsLabel%key while the picker right under it says "Projects & resources".
- Manage Books dialog:
- The tests hide this.
PICKER_BOUND_FIND_KEYS(here) andPICKER_BOUND_CHECKS_SIDE_PANEL_KEYS(checks-side-panel.component.test.tsx:62) now make those keys resolve in the stub, so no consumer test covers the unresolved state anymore. - Suggestion: at minimum, give Find and Checks the same consumer-side fallback the checklist got, and add one test per consumer for the unresolved state. If the Manage Books and Settings pickers are out of scope, list them in the PR body so they aren't lost.
4. Minor — the checklist's ariaLabel skips firstUsableLabel
Attach to: extensions/src/platform-scripture/src/checklist.web-view.tsx:831
In the same object literal, buttonPlaceholder goes through firstUsableLabel but ariaLabel doesn't. While strings are unresolved, both checklist pickers get the same accessible name, "Projects & resources".
Details:
- :827-831 and :962-967: the visible text becomes "Select comparative projects" / "Select primary Scripture text", but the raw-key
ariaLabelis dropped and replaced withDEFAULT_STRINGS.ariaLabel. Before this PR the two pickers had different (though ugly) raw-key names. Now a screen reader user hears two identical toolbar comboboxes. .eslintrc.ai.jsenforcesparanext/require-localized-ariaas an error for*.web-view.tsx, so this is a pattern the repo cares about.- The new checklist tests only check
toHaveTextContent, which is why nothing caught this. - Suggestion: one
firstUsableLabel(...)call at each of the two sites, plus agetByRole('combobox', { name: … })assertion in the new tests.
5. Minor — resolveStrings is slower on every render and isn't memoized
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:216
resolveStrings runs on every render (:728). Each field now goes through isLocalizeKey, which creates a GraphemeString, and that constructor splits the whole string into graphemes.
Details:
grapheme-string.ts:175:this.graphemes = string === '' ? [] : Array.from(splitGraphemes(string))runs in the constructor. The early exit onstartsWith('%')only skips building the offsets array, not the grapheme split.- The picker re-renders on every keystroke in its search box (
setQuery), and the checklist shows two pickers. The/code-reviewpass measured roughly 78× the old spread's cost, and more in the all-raw-keys error state this PR targets. I didn't re-measure, but the constructor code confirms the extra work happens. - Suggestion:
useMemo(() => resolveStrings(props.localizedStrings), [props.localizedStrings]). Using the regex-basedresolveLocalizedStringfrom #1 would also avoid the grapheme split entirely.
6. Minor — two definitions of "unresolved", and grouping labels only get the weaker one
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.groupings.ts:87
readProjectSelectorString only rejects a value that equals its own key, while resolveStrings rejects anything shaped like a key. Grouping labels and section headings never go through resolveStrings, so they only get the weaker check.
Details:
{grouping.label}(project-selector.component.tsx:681) and grouping section headings (:1194) read theavailableGroupingsobjects directly.groupings.test.ts:120(does not treat a DIFFERENT key as unresolved) locks in the weaker behavior. For chrome fields that result is never visible, becauseresolveStringsdrops it afterwards. So that test describes a contract the rest of the code doesn't follow.- It isn't a live bug for the shared keys, since the real failure modes return
value === key. But it is live for consumer-built groupings:manage-books.web-view.tsx:919doesif (localizedName) names.set(...), and a raw key is truthy, so it ends up as a Type section heading.manage-books-dialog.component.tsx:855/903does the same for versification headings. - Suggestion: use one predicate (the shared helper from #1) in both places, and update the "DIFFERENT key" test to match.
7. Minor — new primaryProjectLabel fallback can show a GUID, and the label changes once strings load
Attach to: extensions/src/platform-scripture/src/checklist.web-view.tsx:964
Before this PR, the primaryProjectLabel candidate was never used. Now it is, and it can hold a raw project ID.
Details:
primaryProjectNameis(await pdp.getSetting('platform.name')) ?? projectId, and the error path callssetPrimaryProjectName(projectId).manage-books-sidebar.component.tsx:311explicitly avoids this: "projectIds are GUIDs and would render as a 32-char hex string in the trigger".- Ordering: while unresolved, the trigger shows the project name ("ESVUS16"). Once strings resolve, candidate 1 wins and it changes to the generic "Select primary Scripture text". In
mode="project"the placeholder slot means "nothing selected", so the two states disagree about whether something is selected. The new test atchecklist.web-view.test.tsx:271treats the mid-load state as correct. - Suggestion: drop
primaryProjectLabelfrom the candidate chain, or skip it when it equalsprojectId.
8. Minor — some of the new tests can't catch the regressions they describe
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.test.tsx:587
A few of the new assertions can't fail, or pass without reaching the code they are named for.
Details:
- :587 —
expect(trigger).not.toHaveAccessibleName(expect.stringContaining('%'))can never fail.triggercame fromgetByRole('combobox', { name: 'Projects & resources' })a few lines earlier, which is an exact match, so its name is already known. - :531-553 — the headline test never reaches the
isLocalizeKeybranch.buildProjectSelectorLocalizedStrings(UNRESOLVED_STRINGS)returns allundefined, becausereadProjectSelectorStringalready rejectsvalue === key. Only the!== undefinedbranch runs. (The second test does coverisLocalizeKey, so that branch is tested; the first test's name and comment just claim more than it checks.) - :569 —
%webView_find_projectFilter_selectProject%doesn't exist anywhere in the repo. Find's real key is%webView_find_projectFilter_noOpenProjectsOrResources%. The behavior is still right, but the test presents a made-up key as a real consumer override. - :586 —
/%\w+_/can't match a key without an underscore (for example%clearAll%), andqueryByTextthrows "Found multiple elements" instead of failing clearly if more than one key leaks.queryAllByText(/%[^%\s]+%/)with.toHaveLength(0)is broader. - Both tests render with
openTabs={[]}and noavailableGroupings, so the group-by menu, section headings, and bound-but-closed row never appear. The "nothing may render a raw key" checks therefore can't see most of the 21 strings.
9. Minor — the same explanation is written out at 6 new places
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:196
This PR adds six near-copies of the paragraph "useLocalizedStrings seeds its state with defaultState[key] = key and returns that same state on a platform error…". At that volume, readers stop reading them.
Details:
- The copies added here are at
project-selector.component.tsx:203,project-selector.groupings.ts:75,project-selector.groupings.test.ts:74,project-selector.component.test.tsx:523,checklist.web-view.tsx:121, andproject-selector.test-utils.ts:7. Several share the exact same clause. - The comment in
resolveStrings(:196-213) is 18 lines, split into three labeled sections, inside a function with 5 lines of code. project-selector.test-utils.ts: 19 of its 29 lines are comment, for a one-line helper and a one-line predicate.- The comments are forward-facing, which is good; there are no ticket references or change history. The only problem is volume.
- Suggestion: keep the full explanation once, on
readProjectSelectorString(the exported function that owns the idea), and make the other copies one-line pointers. ForresolveStrings, something like:// Filter rather than spread: the builder emits `undefined` for unresolved fields, which a spread // would write over the English default. Localize-key and blank values are dropped for the same // reason — see `readProjectSelectorString`.
10. Minor — the PR description has errors and hides its key notes
Attach to: PR description (a general PR comment)
Parts of the description are out of date or wrong, and the notes a reviewer needs sit under about 200 lines of pasted review output.
Details:
- The top "Suggested Review Focus" item calls the #2796 conflict "the single most important thing to settle" and says #2796 "reworks this same file". That's no longer true:
gh pr view 2796 --json filesshows it only touchessettings-sidebar*,settings-sidebar-content-search,settings-tab, anddist, notproject-selector*. The real overlap is small. #2796 adds its ownObject.entries(...).filter(value !== undefined)atsettings-sidebar.component.tsx:117-119, with a comment at :112-114 saying ProjectSelector "layers this bag … with a plain spread". Once this PR merges, that comment is wrong and the filter is redundant. Whichever PR merges second should remove them. - "fr, km, zh-hans and zh-hant were showing raw keys throughout the picker and now show English" is wrong.
localization.service-host.ts:339always addsBACKUP_LANGUAGE = 'en'as a fallback, anden.jsonhas all 21 keys, so those locales already got English. The real exposure is the window before strings load and the platform-error path. - "Four consumers wire these keys" leaves out
SettingsSidebarand the three Manage Books dialog pickers (see #3). - The
<!-- review-paratext:summary -->block (checkboxes, "Author response", "Interview Notes", exit codes) is useful review history, but as the PR description it pushes the Exposure note and the note for @Sebastian-ubs to the very bottom. Consider moving it to a PR comment and keeping the description short: what was broken, the two causes, and the widened check.
11. Nit — new hard-coded English label
Attach to: extensions/src/platform-scripture/src/checklist.web-view.tsx:965
'Select primary Scripture text' is a new user-facing English string in a web view, and it duplicates extensions/src/platform-scripture/contributions/localizedStrings.json:723 word for word.
Details: Localization-Guide.md:53 says "Never hardcode English text in code that faces users." The library exception (DEFAULT_STRINGS) is for platform-bible-react components that can't call PAPI, not for a web view that already calls useLocalizedStrings. The string is now in three places (the JSON, this literal, and checklist.stories.tsx), and a typo fix in the JSON, which the immutable-strings rule allows in place, would leave the fallback out of sync. If a hard-coded fallback is still the right choice here, a short comment pointing to the JSON key would make the duplication intentional and easy to find.
12. Nit — Object.fromEntries loses field types
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:214
The Object.entries → filter → Object.fromEntries round-trip turns the spread into { [k: string]: string }, so Required<ProjectSelectorLocalizedStrings> no longer checks the values.
Details: I confirmed this with tsc --strict: { ...D, c: 'oops' } fails with TS2322 when c is a number, but { ...D, ...Object.fromEntries([['c', 'oops']]) } compiles. There's no risk today, because every field of ProjectSelectorLocalizedStrings is string? and partial is typed. It would start to matter if a non-string field is ever added. The typed loop suggested in #1 fixes this for free, and the PR's own summary lists it as an open question.
13. Nit — the vi.mock factory breaks the file's own rule
Attach to: extensions/src/platform-scripture/src/checklist.web-view.test.tsx:109
The hoisted vi.mock('@papi/frontend/react') factory now uses isProjectSelectorSharedKey / localizedValueFor, which are top-level imports. The comment at :124 in the same file says a hoisted factory "must not close over the file's top-level import bindings".
Details: It works only because ./project-selector.test-utils is imported before ./checklist.web-view. If an import sorter or a manual edit reorders them, collection fails with a temporal-dead-zone ReferenceError. manage-books.web-view.test.tsx:71 has the same shape. Either import inside the factory the way :124 describes, or update the rule comment to explain why static imports are safe here.
14. Nit — "so they cannot drift" isn't true
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.groupings.ts:62
isBlankLocalizedValue's doc says the two owners of "unresolved" share it "so they cannot drift on what counts as blank". But it isn't exported from experimental.ts, so checklist.web-view.tsx:130 writes its own candidate.trim() !== '' as a third check, and the two owners already disagree on what counts as a key (#6).
Suggestion: shorten the JSDoc to one line ("Whether a localized value has no visible text; whitespace-only counts as blank."). The shared helper from #1 is what would actually stop the drift.
15. Nit — two comments don't match the code next to them
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.groupings.test.ts:76
- :76 — the comment says the picker would render
"%projectSelector_searchPlaceholder%", but the test below uses%projectSelector_clearAll%. Use the same key in both so a reader checking them against each other isn't thrown off. extensions/src/platform-scripture/src/checklist.web-view.test.tsx:239— this comment describes theuseLocalizedStringsmock but sits right aboveconst OTHER_PROJECTS(:243), which it has nothing to do with. Move it above thedescribe.
16. Follow-up (not blocking) — the same bug elsewhere, and a standard that teaches it
Attach to: extensions/src/platform-scripture/src/checklist.web-view.tsx:128
firstUsableLabel fixes ?? fallback for two lines, but the same dead-?? pattern is still used elsewhere, and the repo's localization standard tells people to write it.
Details:
- A grep for
localizedStrings\[[^]]*\] *\?\?inextensions/src/platform-scripture/src(tests excluded) finds 7 sites. They includemanage-books.web-view.tsx:507(the dock tab title),checklist.web-view.tsx:577in this same file,find.component.tsx:882and:1449, andmanage-books-dialog.component.tsx:619. TypeScript can't flag these becausenoUncheckedIndexedAccessis off. .context/standards/Localization-Guide.md:181-186still tells you to writelocalizedStrings?.['%…%'] ?? 'Select Chapter', and its example (book-chapter-control.component.tsx) has already moved toresolveLocalizedString.- This is outside the diff, and the PR body already leaves the sweep across components out of scope, so I'm not asking for it here. A Jira ticket to (a) export one shared reader and (b) fix the guide would stop this bug from being written again.
Appendix — /code-review max final report (numbered, 30-item cap)
This is the raw output of the /code-review max pass (16 candidates per subagent, 30 in the final report). I checked it before merging it into the comments above. Where I disagree with the raw report, the note says so.
project-selector.component.tsx:216— the check uses the looser of two "is this a key?" tests in the package (isLocalizeKeyvs/^%[^%]*%$/). Merged into comment 1. Correction: the report's example'%s of %s'is wrong. That string doesn't end in%, soisLocalizeKeyreturns false. Real differences are values like'%'or'%d%'.project-selector.component.tsx:216—resolveStringshas notypeof value === 'string'check, so a non-string value crashesisLocalizeKey. Not raised:partialis typedProjectSelectorLocalizedStringswith onlystring?fields, so this can only happen through an untyped caller. It's defensive hardening, not a live bug.checklist.web-view.tsx:831/:967—ariaLabelbare lookup; both pickers share one accessible name. Comment 4.project-selector.component.tsx:728— unmemoizedresolveStringsnow splits graphemes per render (78–232× measured). Comment 5. Confirmed the constructor splits eagerly; the multiplier wasn't re-measured.manage-books-dialog.component.tsx:619— dead??feeds three more pickers, and their wording is dropped. Merged into comment 3.settings-sidebar.component.tsx:135,:176-177— a fifth consumer, half fixed. Merged into comment 3.project-selector.component.tsx:681,:1194— grouping labels skipresolveStrings. Merged into comment 6.checklist.web-view.tsx:962—primaryProjectLabelcan be a GUID. Comment 7.find.component.tsx:975/checks-side-panel.component.tsx:226— fallback turns an informational placeholder into a wrong instruction. Comment 3 (confirmedentext "No open projects or resources").project-selector.component.tsx:1062—|| undefinedis dead, and the''opt-out is gone. Comment 2.checklist.web-view.tsx:962— candidate order causes a label change once strings load. Merged into comment 7.- Seven
localizedStrings[…] ?? fallbacksites remain. Comment 16. manage-books.web-view.tsx:919-920,manage-books-dialog.component.tsx:855/903— raw keys reach section headings. Merged into comment 6.groupings.ts:87vscomponent.tsx:216— the two layers disagree, and a test pins the difference. Merged into comment 6.groupings.ts:62-65— "cannot drift" is false;.trim()also differs fromisWhiteSpace. Comment 14 (theisWhiteSpacepoint was left out as too minor).component.test.tsx:551,:585— the raw-key sweeps run against a DOM that doesn't contain most of the strings. Merged into comment 8.component.test.tsx:527/541— the headline test never reachesisLocalizeKey. Merged into comment 8 (noted that the second test does cover that branch).component.test.tsx:587—not.toHaveAccessibleNamecan never fail. Comment 8.component.test.tsx:617-621— the padded-whitespace test can't detect trimming (thegroupings.test.ts:104version does). Not raised separately: the lower-level test already pins this behavior.component.test.tsx:586—/%\w+_/is too narrow, andqueryByTextthrows on multiple matches. Merged into comment 8.find.component.test.tsx:66,checks-side-panel.component.test.tsx:62—PICKER_BOUND_*_KEYSare hand-maintained. Not raised as its own finding: there's no exported list to derive them from. The real problem (they hide the unresolved state) is in comment 3.- The test-utils stub change means no consumer suite tests the unresolved state for picker keys. Merged into comment 3.
checklist.web-view.test.tsx:111,manage-books.web-view.test.tsx:73— hoistedvi.mockuses top-level imports. Comment 13.checklist.web-view.test.tsx:273—'P1'comes from an incidental mock default, andafterEachdoesn't resetmockProjects. Not raised: fragile but correct today. Mention it only if the author touches that suite again.component.test.tsx:569,:638—%webView_find_projectFilter_selectProject%doesn't exist. Comment 8 (confirmed by grep).localization.util.ts:20—resolveLocalizedStringalready exists. Comment 1.checklist.web-view.tsx:965— hard-coded English duplicateslocalizedStrings.json:723. Comment 11. The adversarial pass disputed this, since a hard-coded fallback is how this whole PR works. I kept it as a Nit becauseLocalization-Guide.md:53applies to web views.component.tsx:214-218—Object.fromEntriesloses types. Comment 12, marked Nit after checking withtsc(no risk today).- Three wrong claims in the PR body (#2796, fr/km/zh, consumer count). Comment 10 (#2796's file list and
BACKUP_LANGUAGEbehavior both confirmed). - No update to the standards or the architecture decisions log; test stubs duplicated (
stubLocalizedStrings, theuseLocalizedStringsmock,localizedValueFor×2). Standards part → comment 16. ADR request not raised: too heavy for a ~20-line bug fix. Duplicated stubs: not raised, since the cross-workspacelocalizedValueForcopy can't be avoided without exporting test helpers fromplatform-bible-react.
Findings from the earlier passes that the adversarial pass dropped
- Performance "no regression" (the adversarial pass's own conclusion): overruled. It relied on the PERF note in
offsetAt, but the constructor atgrapheme-string.ts:175splits eagerly. Kept as comment 5. - "
metadata.jsonfallbackKeyvalues disprove the PR's 'no%-wrapped values' claim": dropped. Those are keys by definition, not localized values; the PR's claim holds. - "
||on undefined is not a nullish test" (on thefirstUsableLabelJSDoc): dropped. The comment explicitly limits it toundefined. - "Every suite here stubs…" is wrong: dropped. The mock is module-level and does apply to every suite. Only the comment's position is off (comment 15).
- The test name at
checklist.web-view.test.tsx:274"has its meaning backwards": dropped. Line number wrong, and the reading was reversed. - Moving
project-selector.test-utils.tsintoplatform-bible-react: dropped. That would ship test utilities in the library's public API.
@katherinejensen00 partially reviewed 20 files and all commit messages, and made 2 comments.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on jolierabideau).
lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx line 214 at r1 (raw file):
// the trigger with no text and no accessible name, which reads as a broken control rather than a // wrong label — `isBlankLocalizedValue` keeps that on the fallback path too. const resolved = Object.entries(partial).filter(
1. Important — reuse resolveLocalizedString
Attach to: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:214
This package already has a tested helper for this exact problem, resolveLocalizedString in lib/platform-bible-react/src/utils/localization.util.ts:20, so this PR adds another copy of the rule instead of using it.
Details:
resolveLocalizedString(value, fallback)exists for this bug. Its doc saysuseLocalizedStringsseeds{ [key]: key }, so?? 'Default'never falls back. It has its own test file, andBookChapterControlandRecentSearchesalready use it (15 call sites).- After this PR, the "is this value usable?" rule exists in several places that don't quite agree:
resolveLocalizedString,readProjectSelectorString(project-selector.groupings.ts:87),resolveStrings(here),firstUsableLabel(checklist.web-view.tsx:128), and older ones in the renderer (localizedOrEnglish,createCrashedViewLocalizer). - It isn't a drop-in swap, and the differences matter:
resolveLocalizedStringchecks!value, so it catches''but not whitespace-only strings. This PR treats whitespace-only as unresolved on purpose.- Its key test is
/^%[^%]*%$/.isLocalizeKeyis looser (startsWith('%') && endsWith('%')): a bare'%'or'%d%'counts as a key. No shipped translation looks like that today. ButfirstUsableLabelalso runs this check onprimaryProjectName, which a user chooses, so a project named like%ABC%would be skipped.
- Suggestion: extend
resolveLocalizedStringto cover whitespace-only values, then writeresolveStringsas a typed loop overObject.keys(DEFAULT_STRINGS)that calls it for each field. That removes the third copy of the rule and also fixes #5 and #12. ThefirstUsableLabelcandidate chain can then sit on the same helper.
…e unresolved
The picker rendered raw `%projectSelector_*%` keys at the user. Two defects
compounded, and either alone leaves the string wrong:
`readProjectSelectorString` accepted a value that is still its own key.
`useLocalizedStrings` seeds `defaultState[key] = key` and returns that same
state on a platform error, so an unresolved entry arrives as the key — a
string, which the bare `typeof` check passed through. The window before
strings resolve showed raw keys; a localization failure showed them for good.
`resolveStrings` then spread `{ ...DEFAULT_STRINGS, ...partial }`, but
`buildProjectSelectorLocalizedStrings` emits a property for every field,
holding `undefined` where the lookup resolved nothing. An explicit `undefined`
in a spread overwrites the default, so the documented English fallback never
applied — fixing only the guard would have turned a raw key into a blank
string. Undefined values are now dropped before the merge, which also repairs
the plain missing-key path.
Four consumer suites stubbed `useLocalizedStrings` by echoing each key back as
its own value, which is precisely its unresolved state, and asserted the raw
keys as display text — so they were pinning the defect as correct. Their stubs
now resolve the shared `%projectSelector_*%` block to a value that names its
key without being it. Each component's own keys stay identity-mapped: they do
not pass through the guard, and identity keeps those assertions independent of
the shipped English wording.
Both fixes verified falsifiable: reverting either one fails the new
component-level test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Widen the unresolved-value guard from the shared %projectSelector_*% block to every value that reaches the string bag. `find` and `checks-side-panel` override buttonPlaceholder, commandEmptyMessage and ariaLabel with bare lookups of their own %webView_…% keys; an unresolved lookup is a defined string, so those overwrote the English default and the picker still rendered a raw key — including as its accessible name. resolveStrings now drops a value that is undefined, is itself a localize key, or is blank after trimming. Each arm is independently pinned by test. Also: treat a blank value as unresolved in readProjectSelectorString; keep the consumer's own wording on the unresolved path in checklist.web-view.tsx, whose `??` fallbacks were unreachable because a nullish test never sees a raw key; dedupe the test helper into project-selector.test-utils.ts; drive the test stubs off PROJECT_SELECTOR_STRING_KEYS instead of a hardcoded prefix; and add mixed-partial coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eader Address review of #2829. Replace this branch's three local copies of the "is this localized value usable?" rule with the package's existing `resolveLocalizedString`, extended to treat whitespace-only values as unresolved. It now backs a predicate (`isResolvedLocalizedValue`) and a candidate chain (`firstResolvedLocalizedString`), both exported from `platform-bible-react/experimental` so consumers judge their own `%webView_…%` lookups the same way the picker does. Consequences: - `resolveStrings` merges field by field against `DEFAULT_STRINGS` instead of round-tripping through `Object.entries`/`Object.fromEntries`, so a missing or misspelled field is a compile error again. It is memoized; the previous form ran `isLocalizeKey` — and so a full grapheme split — on every field on every render, including every keystroke in the picker's search box. - `ariaLabel: ''` is a deliberate "no accessible name" opt-out again, matching `RecentSearches`. The blank check had made `|| undefined` at the trigger unreachable. - Find and the checks side panel keep their own English when their overrides are unresolved. Both had been falling through to the picker's generic "Select a project", which contradicts a placeholder whose job is to report that there is nothing to pick. - Both checklist pickers derive visible text and accessible name from one value, so screen readers no longer hear two identically named comboboxes. The primary picker no longer falls back to the project name, which could be a raw GUID and changed the label once strings loaded; that leaves the `platform.name` read with no consumer, so it goes too. - `readProjectSelectorString` uses the shared predicate, so grouping labels — read straight off the grouping objects, never through the merge — get the same guard as every other string. Tests: pin the `ariaLabel` opt-out and the whitespace-only case separately; add an unresolved-state case per consumer; broaden the raw-key sweeps to any `%…%` key and run them against a DOM with the group-by menu open. Each new guard was confirmed falsifiable by reverting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7b60770 to
91c6dfd
Compare
|
Rebased onto latest 1 — reuse
|
…imports
The hoisted `vi.mock('@papi/frontend/react')` factories in the checklist and
manage-books suites read `isProjectSelectorSharedKey` and `localizedValueFor`
through the file's top-level import bindings, which the rule comment in those
same files says a factory must not do. It works only because the test-utils
import happens to precede the web view's; reordering the imports would fail
collection with a temporal-dead-zone ReferenceError. Import inside the factory
instead, aliased so the in-factory bindings do not shadow.
Also swap `%webView_find_projectFilter_selectProject%`, which exists nowhere in
the repo, for Find's real `%webView_find_projectFilter_noOpenProjectsOrResources%`,
so the test stops presenting an invented key as a consumer override.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round-1 review addressed across 91c6dfd and 9112253. 15 fixed, 1 deferred to a ticket. Fixed1 — reuse 2 — 3 — consumer wording. Find and the checks side panel carry their own English now, so "No open projects or resources" and "No project" survive an unresolved lookup instead of becoming "Select a project". Each gained an unresolved-state test — the case their 4 — checklist 5 — per-render grapheme split. Gone twice over: the regex path replaces 6 — two definitions of "unresolved". One predicate. 7 — 8 — tests that can't catch their regression. The 9 — six copies of the explanation. Down to three, one per package where it's load-bearing for that file's reader. 11 — hard-coded English. Kept — it's how the whole fallback works — with a comment naming the 12 — 13 — 14 — "so they cannot drift". 15 — comments beside the wrong code. Both fixed: the 10 — PR description. Rewritten. The #2796 and fr/km/zh corrections are in; the consumer count is three (four pickers); the Deferred16 — the same bug elsewhere, and the standard that teaches it. Filed as PT-4673 under PT-4530 — export one shared reader, rewrite VerificationBranch is up to date with One thing worth knowing if you re-run locally: the three Storybook-project failures on |
katherinejensen00
left a comment
There was a problem hiding this comment.
PR Review Report: PR #2829 (round 2)
Repository: paranext/paranext-core
PR: #2829 — "Fall back to English when ProjectSelector's localized strings are unresolved"
Author: @jolierabideau · Branch: fix/project-selector-unresolved-localization-keys
Reviewed at: 9112253459a against merge-base e8ffa3e7488
Date: 2026-09-18
Mode: dry-run (not posted — drafted for the user to post)
Reviewers: security, scope, ux, tests, clarity (panel auto-narrowed: no C# files → contracts/architecture dropped)
CI: all green — 3 Build jobs, CodeQL, Analyze (csharp + javascript). Reviewable shows 1 discussion left.
lib/platform-bible-react/dist/ was not reviewed (generated). It IS rebuilt and in sync: the dist diff
adds all three new declarations to dist/experimental.d.ts, and CI's "no files changed after build"
check passes.
Round-1 verification
Round 1 raised 16 findings. 15 are fixed and verified; 1 (#16) is deliberately deferred.
| # | Round-1 finding | Verdict |
|---|---|---|
| 1 | Reuse resolveLocalizedString instead of a 3rd copy of the rule |
Fixed — one rule in localization.util.ts; isBlankLocalizedValue/firstUsableLabel gone. See new finding F/G on the shape of the result |
| 2 | ariaLabel: '' opt-out removed; || undefined dead |
Fixed — carve-out at project-selector.component.tsx:209-212, || undefined reachable at :1108, test split in two |
| 3 | Generic English replaced consumer wording | Fixed for find + checks-side-panel, English verbatim vs localizedStrings.json. Manage Books + Settings deferred → see B |
| 4 | Both checklist pickers got the same accessible name | Fixed — distinct in every state, incl. unresolved |
| 5 | Grapheme split per render, not memoized | Fixed on the expensive half (no GraphemeString at all now). The useMemo itself is a no-op → see E |
| 6 | Two definitions of "unresolved"; grouping labels on the weaker one | Fixed — one predicate, DIFFERENT key test inverted |
| 7 | Fallback could show a GUID; label changed once strings load | Fixed — primaryProjectName state + platform.name read deleted outright |
| 8 | Vacuous assertion / test not reaching its branch / nonexistent key | Mostly fixed — not.toHaveAccessibleName gone, key replaced with a real one. One new sweep is vacuous → see C |
| 9 | Same explanation at 6 places | Fixed in source (:199, groupings.ts:63 now point at the predicate). Recurs in 7 test files → see L |
| 10 | PR description: stale #2796 warning, wrong fr/km/zh claim, pasted review summary | All three fixed. New body issues → see D |
| 11 | Hard-coded English duplicating localizedStrings.json |
Fixed — all 8 fallbacks verbatim-correct, duplication justified at each site |
| 12 | Object.fromEntries round-trip loses field types |
Fixed — field-by-field merge |
| 13 | Hoisted vi.mock factory closed over a top-level import |
Fixed (commit 9112253). Justifying comment now self-contradictory → see I |
| 14 | "So they cannot drift" was untrue | Fixed — comment gone |
| 15 | Comment named searchPlaceholder but test used clearAll |
Fixed |
| 16 | 7 more sites with the same bug; Localization-Guide still teaches it | Deferred to PT-4673 — but the ticket appears nowhere in the tree → see A |
Summary table
| Perspective | Blocking | Warning | Suggestion |
|---|---|---|---|
| Security | 0 | 0 | 0 |
| Scope | 0 | 3 | 2 |
| Tests | 0 | 1 | 3 |
| Clarity | 0 | 0 | 5 |
| UX | 0 | 1 | 1 |
| Total | 0 | 5 | 11 |
No blocking findings. Recommended event: COMMENT.
New findings (round 2)
A — Important. The PT-4673 deferral is recorded nowhere in the tree, and the guide still teaches the bug
grep -rn PT-4673 over the whole worktree (*.ts, *.tsx, *.md) returns zero hits. The deferral
lives only in the PR body and in Jira, and the PR body does not survive as a searchable pointer after
squash-merge. Raised independently by the scope and UX passes.
.claude/rules/code-quality/forward-facing-comments.md explicitly keeps "an open PT-XXXX for
follow-up work deliberately not done in this PR — best written as a TODO naming the open ticket".
Two places to add one:
lib/platform-bible-react/src/utils/localization.util.ts:16— the canonical doc block describing
the broken idiom is the natural home for aTODO(PT-4673)noting the remaining sites..context/standards/Localization-Guide.md:181-186— still teaches the exact dead form
(localizedStrings?.['%webView_bookChapterControl_selectChapter%'] ?? 'Select Chapter'). This is
the thing that regenerates the defect: an author writing a new component from that guide today
reproduces it. RootCLAUDE.md("Promote settled conventions") asks that a hardened rule be
folded into the relevant standard in the same change, not left to a follow-up.
B — Important, decision needed. Should Manage Books land here?
File: extensions/src/platform-scripture/src/manage-books.web-view.test.tsx:67
The author asked: "say the word if you'd rather they land here." The scope and UX passes converged on
the same answer: split the deferral — land Manage Books, keep Settings deferred.
- The two halves are not alike.
settings-tab.component.tsx:305sits in files #2796 is actively
editing, so deferring it avoids a real conflict, and the body says so.
manage-books-dialog.component.tsx:617-620's
t = (key, fallback) => localizedStrings[key] ?? fallbackhas no such excuse — by the author's own
account it is a single function body covering all three of its pickers, in the same extension and
picker family as the three consumers this PR does fix. The fix is mechanical:
localizedStrings[key]→firstResolvedLocalizedString(localizedStrings[key], fallback), reusing
the helper this PR already exports; every call site already passes a sensible English fallback, so
no new strings. - This PR is already inside Manage Books. The diff reworks
manage-books.web-view.test.tsx's
useLocalizedStringsstub so the shared%projectSelector_*%block resolves. That leaves Manage
Books' suite passing through the fixed path while the component keeps the bug — the least
defensible resting place, because the suite now looks like it covers Manage Books' localization
and does not. - Leaving it also makes the unresolved state actively worse than before in one respect: the Copy
"From" picker (:2351), the Create "Based on" picker (:2647) and the sidebar picker
(manage-books-sidebar.component.tsx:310) all now resolve to the library's identical generic
'Projects & resources'/'Select a project'— three comboboxes in one dialog with the same
accessible name, where before they at least had distinct (ugly) raw-key names.
If the PR stays as-is instead, the Manage Books test-stub change should not land here either.
C — Important. One of the new raw-key sweeps cannot fail
File: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.test.tsx:566-567
In the first test of the new unresolved localized strings block, both RAW_KEY sweeps are vacuous,
and the comment at :564 calls them "the load-bearing half" — the opposite of what they are. The test's
own comment at :534 explains why: buildProjectSelectorLocalizedStrings(UNRESOLVED_STRINGS) rejects
key-as-value itself, so the bag reaches the merge as all-undefined. Every value the render can
produce is then undefined, an English DEFAULT_STRINGS entry, or the literal ariaLabel: 'Project';
availableGroupings are literals and SAMPLE_PROJECTS (:63-67) contain no %. No raw key can enter
this DOM under any behavior of resolveStrings, so reverting the production fix cannot trip these two
lines — the English assertions at :561-562 are what actually fail.
Same class as round-1 #8. In the second test (:600-601) the identical two lines ARE load-bearing,
because the consumer overrides there are real raw keys.
Fix: drop the sweeps from the first test and let the English assertions carry it, or feed that test a
bag that actually contains raw keys at the merge.
D — Warning. PR body: undocumented blast radius, inconsistent counts, review-process residue
File: PR description
- The body documents the widening ("it now also treats whitespace-only values as unresolved") but
never says it changes behavior at call sites this PR is not about.resolveLocalizedStringhas 15
pre-existing callers outside the picker — 9 inbook-chapter-control.component.tsx, 4 in
book-chapter-control.navigation.ts, 2 inrecent-searches.component.tsx. That fact is in the
author's round-2 comment, not the body, and the body is what a reader of the squash-merge commit
sees. Add one line to "The fix". - Site count is inconsistent across artifacts: the body says "~83" with a breakdown summing to 75;
the first round-2 comment says "~74". Pick one number and one method. - "On #2796" and "Scope note on locales" are written as corrections of earlier revisions of the
description ("Earlier revisions of this description called #2796 a major conflict. That was
wrong"), and "Worth confirming CI covers them somewhere." is a reviewer-directed ask. Round-1
#10 was about exactly this class of residue surviving into the body: state the current facts
directly and move the asks to a PR comment.
E — Warning. The useMemo on resolveStrings is a no-op in exactly the state this PR targets
File: lib/platform-bible-react/src/components/advanced/project-selector/project-selector.component.tsx:774
Verified directly: src/renderer/hooks/papi-hooks/use-localized-strings-hook.ts:45-48 builds
const defaultState: { [key: string]: string } = {} in the render body, and :54 returns
isPlatformError(localizedStrings) ? defaultState : localizedStrings — a fresh object every render on
the error path, which is the permanent raw-key state this PR exists for. So localizedStrings changes
identity every render, the consumer's own useMemo recomputes, the prop object is fresh, and this memo
misses on every keystroke in the search box.
Not worth blocking now that the work is ~16 cheap regex tests — round-1 #5's expensive half (the
per-render grapheme split) is genuinely gone. But the memo does not do what the PR body claims. Either
give it a value-based dependency (or make useLocalizedStrings return a stable defaultState), or
drop it so no one reads it as a guarantee.
Consumer call sites are otherwise fine: checklist.web-view.tsx:744, find.component.tsx:974 and
checks-side-panel.component.tsx:225 all wrap in useMemo keyed on localizedStrings.
F — Warning. Three new exports on experimental, one external consumer
File: lib/platform-bible-react/src/experimental.ts:40
Every extension import in the diff is firstResolvedLocalizedString (checklist.web-view.tsx:18,
checks-side-panel.component.tsx:21, find.component.tsx:49). isResolvedLocalizedValue and
resolveLocalizedString are used only inside lib/platform-bible-react/src, where the
@/utils/localization.util import already reaches them — nothing outside the library imports either.
So two of three new public exports are speculative surface, and the comment at :35-38 ("shared with
consumers so a web view merging its own %webView_…% lookups judges them the same way") is
aspirational rather than a description of the code.
Also a name collision worth avoiding:
extensions/src/platform-scripture-editor/src/scripture-text-grid/view-options-notice.utils.ts:29
exports its own resolveLocalizedString(localizedStrings, key) — different signature, different
contract, untouched by this PR. Exporting this one under the same name from experimental sets up a
genuinely confusing choice for the next person who reaches for it there. Either fold that copy onto
the shared helper or note in the body that it was left out.
Suggestion: export firstResolvedLocalizedString now, add the other two when a consumer needs them —
or, if the trio is deliberate (a defensible argument: the predicate is the definition the docs point
at), say so in the export comment so the unused pair doesn't read as an oversight.
G — Suggestion. firstResolvedLocalizedString is variadic but never called with more than 2 args
File: lib/platform-bible-react/src/utils/localization.util.ts:51
All 8 call sites pass exactly two arguments, the second a literal English string that always resolves:
checklist.web-view.tsx:777, :781; checks-side-panel.component.tsx:229, :233, :237;
find.component.tsx:979, :983, :987. In that shape it is resolveLocalizedString(value, fallback)
with a worse return type — string | undefined, so every call site's result is nullable when it never
can be. The TSDoc's motivating case (a consumer override, then a setting, then English) has no caller.
Consider collapsing those 8 sites onto resolveLocalizedString and adding the variadic form back when
a third candidate actually appears. That also shrinks the newly exported triple to the one function
consumers need, resolving F.
H — Suggestion. Two new tests restate the previous assertion instead of pinning a changed line
Files: extensions/src/platform-scripture/src/find/find.component.test.tsx:328,
extensions/src/platform-scripture/src/checks/checks-side-panel/checks-side-panel.component.test.tsx:187
With projects: [] the trigger's only text is buttonPlaceholder, which the test immediately above
already asserts exactly ('No open projects or resources' / 'No project'). If that regressed to a
raw key, the first test fails too — so neither of these can fail independently.
Meanwhile the other new fallbacks on the changed production lines are unpinned: neither new test opens
the popover, so commandEmptyMessage → 'No projects found' (find.component.tsx:981-984,
checks-side-panel.component.tsx:231-240) is never exercised on the unresolved path, and the
pre-existing suites can't cover it because stubLocalizedStrings now hands the picker-bound keys a
resolved value. Better spent opening the picker under UNRESOLVED_STRINGS, typing a non-matching
query, and asserting 'No projects found' — as the library test at
project-selector.component.test.tsx:590-596 does.
I — Suggestion. Two comments 25 lines apart give contradictory vi.mock hoisting rules
File: extensions/src/platform-scripture/src/checklist.web-view.test.tsx:131
The round-2 code fix is correct — the @papi/frontend/react factory at :105 is async and imports the
helpers inside itself, which is unconditionally safe under vitest hoisting. But :106-107 states flatly
that "a hoisted vi.mock factory must not close over the file's top-level import bindings", while
:131-132 carves out an exception saying bindings from ./project-selector.test-utils are "safe by
contrast". The stated reason for the carve-out is also not the reason it holds: what matters is whether
the helper module is evaluated before the factory runs (it is, because checklist.web-view is loaded
lazily inside getChecklistWebView()), not whether "the mocked module graph imports it". A reader
following :131 could move the helpers back into a top-level closure and reintroduce the
temporal-dead-zone failure :106 warns about.
Keep one rule — import inside the factory, full stop — and drop the carve-out.
manage-books.web-view.test.tsx:67-71 applies the same fix without it.
J — Suggestion. The renderer's two older copies stay on the weaker rule
File: lib/platform-bible-react/src/utils/localization.util.ts:25
localizedOrEnglish (src/renderer/components/overlays/overlay-connection-lost.component.tsx:88-94)
and createCrashedViewLocalizer (src/renderer/components/crashed-view.util.ts:102-111) both test
!value || value === key — identity against the specific key only, no blank/whitespace check. So a
value that is a different %…% key, or whitespace-only, still renders at the user on the
crash/connection-lost screens, which is exactly the state those screens exist for. Out of scope to
change here; worth a one-line pointer or a body note so the next reader knows they were left on the
weaker rule deliberately.
K — Suggestion. An Architecture-Decisions entry looks warranted
File: lib/platform-bible-react/src/experimental.ts:35
Nothing under .context/ is touched, and no existing slug covers the ground (adr-library-string-keys-ship-in-shell-assets
is about where string values ship, not how an unresolved one is detected). Two decisions here meet
root CLAUDE.md's bar:
- One shared definition of "unresolved localized value", exported from
experimentalso consumers
judge their own%webView_…%lookups by the component's rule — a new pattern plus new top-level
public surface, and exactly what the next component author will otherwise re-derive as a fourth
local copy. - A consumer owns its English fallback rather than inheriting the component's — visible in
find
keeping "No open projects or resources" andchecks-side-panelkeeping "No project".
One entry covering both would also be the right home for the PT-4673 deferral rationale and the
whitespace widening. Insert in byte-order slug position (LC_ALL=C sort), not at the end: a slug
like adr-unresolved-localized-value-is-one-predicate lands between adr-tour-offered-in-both-modes
and adr-unresolvable-spdx-operators-drop-the-dependency.
L — Suggestion. The seeding explanation is now repeated in 7 test files
File: extensions/src/platform-scripture/src/find/find.component.test.tsx:311
Round-1 #9 is fixed on the source side, but the useLocalizedStrings key-as-value seeding is
re-explained in near-identical prose at checklist.web-view.test.tsx:112 and :246,
checks-side-panel.component.test.tsx:169, find.component.test.tsx:311,
manage-books.web-view.test.tsx:74, project-selector.component.test.tsx:523,
project-selector.groupings.test.ts:24. Now that project-selector.test-utils.ts is the shared home
for these stubs, the explanation could live once in its TSDoc with the rest saying "see
localizedValueFor". PR-level note, not per-site.
Adjudicated down (raised by a pass, dropped by me on verification)
Two UX findings described real problems but attributed them to this PR. Both are pre-existing; I
verified against the merge-base and am not raising them against #2829.
- "
aria-labelreplaces the trigger's content, so a screen-reader user never hears the selected
project's short name." Real a11y issue, but not introduced here: at merge-base the trigger
already hadaria-label={strings.ariaLabel || undefined}on therole="combobox"Button
(:1037),DEFAULT_STRINGS.ariaLabelalready existed (:172), and the checklist already passed
anariaLabelfor both pickers (:813,:946). The PR changed which string, not the mechanism.
Worth its own ticket for the library. - "New wrong state: the primary-project trigger reads 'Select primary Scripture text' while the
project list loads, and noisLoadingis passed." Also pre-existing and textually identical to
before:%markersChecklist_toolbar_primaryProject%is verbatim "Select primary Scripture text"
(localizedStrings.json:723), so merge-base rendered the same text in that window once strings
loaded. TheisLoading={isLoading}at:1040belongs toChecklistTool, not the picker — a
pre-existing gap the PR neither creates nor worsens.
Also noted and not raised: the whitespace widening's scope. The scope pass's verdict, which I agree
with, is keep it here. The old body was if (!value || PATTERN.test(value)) return fallback, so ''
already went to the fallback; the only newly diverted input is a translation that is non-empty but
trim()s to empty, which in shipped locale JSON is a data defect and never an intentional opt-out.
The two intentional-blank sites (recent-searches.component.tsx:97 and now resolveStrings) both
guard on ariaLabel === '' before calling the helper, so the widening can't swallow them. The
obligation is documentation (finding D), not separation.
Security found nothing: no secrets, no widened sandbox surface, no ReDoS in /^%[^%]*%$/ (anchored,
single negated class, no nesting), and the GUID leak path was deleted rather than patched.
Line validation
All findings anchored to lines in the PR's version of each file and spot-checked against the files in
the PR-head worktree. No corrections needed. D anchors to the PR description (no line).
@katherinejensen00 partially reviewed 28 files and all commit messages, and made 1 comment.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on jolierabideau).
What was broken
ProjectSelectorrendered raw%projectSelector_*%localization keys at the user instead oflocalized text or its English fallback — live on
main, introduced by #2673.Two compounding causes:
useLocalizedStringsseeds its state withdefaultState[key] = keyand returns that same state on a platform error, so a lookup hands backthe literal
'%projectSelector_clearAll%'. Atypeof value === 'string'test accepts it, and?? 'Default'never fires on it.undefinedbeat the default.buildProjectSelectorLocalizedStringsemits aproperty for every field, and a plain
{ ...DEFAULT_STRINGS, ...partial }treats apresent-but-
undefinedproperty as a real write. Fixing only (1) would have turned "renders a rawkey" into "renders a blank string".
The fallback has to hold across three windows: before strings resolve, after a platform error, and
after a successful resolve.
The fix
One shared reader, rather than a new copy of the rule.
lib/platform-bible-react/src/utils/localization.util.tsalready owned this problem (
resolveLocalizedString, used byBookChapterControlandRecentSearches); it now also treats whitespace-only values as unresolved and exposes two moreshapes, all three exported from
platform-bible-react/experimental:isResolvedLocalizedValue(value)— the predicate: rejectsundefined, a%…%key, and blank text.resolveLocalizedString(value, fallback)— one value.firstResolvedLocalizedString(...candidates)— an ordered chain, for call sites with more than onesource to try before reaching a literal they own.
resolveStringsmerges field by field againstDEFAULT_STRINGS, so theRequired<…>return typecatches a missing or misspelled field, and it is memoized rather than re-running per render.
ariaLabel: ''remains a deliberate "no accessible name" opt-out, matchingRecentSearches.Whitespace-only is not that opt-out.
Consumers
Six consumers wire these keys. This PR covers three of them (four pickers):
checklist(2 pickers)findchecks-side-panelmanage-booksmanage-books-dialog(3 pickers)settings-tab/SettingsSidebarDeliberately out of scope, and tracked in PT-4673:
manage-books-dialog.component.tsx:619defines
t = (key, fallback) => localizedStrings[key] ?? fallback, whose??never fires, feedingthe Copy "From", Create "Based on" and sidebar pickers; and
settings-tab.component.tsx:305passes abare lookup. Repo-wide there are ~83
localizedStrings[…] ?? fallbacksites with the same dead??— 35 inplatform-bible-react, 22 inplatform-scripture-editor, 11 insrc/renderer/components, 7 inplatform-scripture, the rest scattered — though not all are livedefects, since not every bag comes from
useLocalizedStrings. And.context/standards/Localization-Guide.md:181-186still teaches the idiom. That pair isPT-4673, not this PR.
On #2796
Earlier revisions of this description called #2796 a major conflict. That was wrong: #2796 touches
settings-sidebar*,settings-sidebar-content-search,settings-tabanddist— notproject-selector*. The real overlap is small. #2796 adds its ownObject.entries(...).filter(value !== undefined)atsettings-sidebar.component.tsx:117-119, with acomment at :112-114 saying ProjectSelector "layers this bag … with a plain spread". Once this PR
merges that comment is wrong and the filter is redundant — whichever merges second should remove
both.
Scope note on locales
An earlier revision claimed
fr/km/zh-hans/zh-hantwere showing raw keys. That is also wrong:localization.service-host.ts:339always appendsBACKUP_LANGUAGE = 'en'to the fallback chain anden.jsoncarries all 21 keys, so those locales already got English. The real exposure is the windowbefore strings load and the platform-error path.
Verification
npm run typecheck,npm run lint(0 errors),npm test— all green, 11 projects.platform-scripturesuites run separately (80 passing): the root vitest config'sincludeexcludesextensions/src/**, sonpm testfrom the root never runs them. Worthconfirming CI covers them somewhere.
lib/platform-bible-react/dist/rebuilt and committed — theextensions/suites resolve thelibrary through its committed bundle, so this is load-bearing, not precautionary.
Note for @Sebastian-ubs
This is in the
%projectSelector_*%localization work from #2673 — flagging it rather than leavingit to be found in review.
🤖 Generated with Claude Code
This change is