Skip to content

Transition to Tanstack Query - #159

Closed
spencerrlongg wants to merge 54 commits into
developfrom
feature/tanstack-query
Closed

Transition to Tanstack Query#159
spencerrlongg wants to merge 54 commits into
developfrom
feature/tanstack-query

Conversation

@spencerrlongg

Copy link
Copy Markdown
Collaborator

Drafting

spencerrlongg and others added 30 commits June 19, 2026 14:04
general.ok had no equivalent in Snipe-IT's lang files, so it won't
get a free Crowdin TM match; mobile.* is reserved for strings unique
to this app
pushes source strings to Crowdin on every push to develop; pulling
translations back is handled by Crowdin's native GitHub integration
(configured on crowdin.com), same split as the main app
installed i18next's own types only support 'v4' (the default) for
compatibilityJSON; 'v3' isn't a valid option anymore. Also splits
audit_session_count into _one/_other CLDR suffixes so Crowdin can
present separate plural slots to languages that need them (Russian,
Arabic, Polish, etc.) instead of forcing one string for every count
…b integration

Skipping Crowdin's native GitHub repo integration by choice, not
because it's blocked. This Action replicates the pull/PR side of that
integration via the CLI Action instead, triggered on push to develop
plus manual dispatch.
aa-ER (Afar/Eritrea) is the locale slot the snipe-it Crowdin project
uses for its In-Context localization pseudo-language, not a real
translation target. Its strings are all crwdns/crwdne placeholder
markers. Excluding it going forward via
--exclude-language=aa in crowdin-download.yml.
aa-ER is the locale slot the snipe-it Crowdin project uses for its
In-Context localization pseudo-language, not a real translation
target -- its strings are crwdns/crwdne placeholder markers, not
actual Afar text. Excluding it so future pulls don't ship that file.
--exclude-language=aa passed through download_translations_args had no
effect -- aa-ER still came through on the next pull (verified via a
manual workflow_dispatch run). Using crowdin.yml's per-file
excluded_target_languages instead, since it's config the project
itself respects on both push and pull, rather than a runtime flag that
may not be honored by whatever CLI version the Action's Docker image
bundles.
aa-ER isn't a real project target language; it's Crowdin's synthetic
In-Context pseudo-language pack. Referencing 'aa' in
excluded_target_languages made the project reject it outright
("Project doesn't have 'aa' language(s)"), failing every
crowdin-upload.yml run on push to develop. Reverting; the real fix
needs to strip the pseudo-language file after download instead of
trying to configure it away project-side.
Same issue as before -- aa-ER is Crowdin's In-Context pseudo-language
slot, not a real translation. The exclude-language CLI flag didn't
stick (see #151 for the real fix via crowdin.yml), so stripping it
manually from this PR.
i18n/index.js only ever registered en-US as a resource, so pulled
translations had nothing loading them regardless of device language.
Statically import and register all 75 real locale files (skipping the
aa-ER pseudo-language pack).

Also add supportedLocales to the expo-localization plugin config in
app.json, since iOS only reports languages an app has explicitly
declared via CFBundleLocalizations -- without it, getLocales() never
returns anything but English no matter what the device is set to.
Verified against the actual plugin source (no transformation, passes
codes straight into Info.plist) and confirmed all 75 locale codes are
valid BCP-47 tags.

Device locale resolution now tries an exact tag match first, then
falls back to a base-language match (e.g. de-US -> de-DE) before
defaulting to en-US, since real devices commonly report a
language/region combination that doesn't exactly match one of our
translated locale files.
Prefix matching returned the first resource key sharing a base language,
which is wrong when several variants of one language ship. Five base
languages have multiple variants (en, es, zh, pt, de), and the zh case
was a real bug: Traditional Chinese devices matched zh-CN and were
served Simplified.

Add a local Expo module wrapping the platform APIs built for exactly
this: Bundle.preferredLocalizations(from:) on iOS and
LocaleListCompat.getFirstMatch() on Android. Both apply the OS's own
CLDR resolution. Apple's TN2418 warns against hand-rolling this.

Prefix matching stays as the fallback for web, which has no native
counterpart, and for a platform reporting no match at all.

An earlier attempt used @formatjs/intl-localematcher, but it needs
Intl.Locale, which Hermes does not implement -- match() threw, and a
catch turned that into a silent fallback to English. Polyfilling it also
carries a documented startup regression (formatjs#4276), so the native
route is both correct and cheaper here. No catch is wrapped around the
native call on purpose: if the module is not linked, requireNativeModule
throws a clear error rather than quietly degrading.

Also anchor the android/ and ios/ gitignore rules to the repo root.
Unanchored, they matched modules/*/android and modules/*/ios too and
silently excluded this module's native source from the commit.
create-expo-module scaffolds a standalone publishable package, so it
emitted an MIT LICENSE carrying Expo's own copyright line ("650
Industries, Inc.") and a podspec full of placeholder metadata. The
license asserted Expo's copyright over code written here and has no
purpose in a --local module that never leaves this repo; removed it.
Filled in the podspec's summary, description, author and homepage.
react-native-web isn't a dependency, so the web target can't build
regardless and the .web.ts stub was dead code. Removed it along with the
stale comment in i18n/index.js citing web as a reason for the fallback
path -- that path now only covers the platform reporting no match.
It was the only .ts file in the project and typescript isn't a
dependency, so `expo run:android` refused to build and demanded
typescript@~6.0.3 be installed. Babel transpiles TS without a tsconfig,
which is what I checked originally, but the Expo CLI separately gates on
the dependency existing as soon as it sees a .ts file. Plain JS keeps the
project TypeScript-free; the type information moves to a comment.

Also ignore Gradle output under modules/*/android/build. Anchoring the
android/ and ios/ rules to the repo root stopped them covering local
modules, so a build directory became trackable and 110 generated files
were briefly staged along with the source.
LocaleList.getFirstMatch is not the counterpart to iOS's
preferredLocalizations(from:). It returns one of the *user's* locales
rather than one of the supported ones, so it cannot name a translation
file: a zh-Hant device made it return "zh-Hant", which matches no
resource key, and the prefix fallback then picked the first zh-* key
(zh-CN) and served Simplified Chinese to a Traditional reader -- exactly
the bug this module exists to fix.

android.icu.util.LocaleMatcher would be the real equivalent but is not in
Android's public SDK (absent from android.jar in both API 35 and 36).
ULocale.addLikelySubtags is available, and is the same CLDR "maximize"
primitive Intl.Locale.maximize() provided in the abandoned JS approach.
Maximizing both sides fills in implied scripts (zh-Hant -> zh-Hant-TW,
zh-CN -> zh-Hans-CN), so matching on language+script+region, then
language+script, then language picks Traditional for Traditional.

Every ULocale method used is API 24 per the SDK's own api-versions.xml,
matching Expo's minSdk, so no version guard is needed.

Verified on an API 36 emulator with the per-app locale set to zh-Hant:
renders 資產/最近操作活動/稽核 (Traditional) where it previously showed
资产/最近活动/盘点 (Simplified).
Tapping "Audit Due" or "Audit Overdue" on the home screen sent you to the
audit list, which always opened on the Due tab. Both badges now pass a tab
param and the list screen seeds its initial tab from it.

Closes #125
Shared groundwork for the TanStack Query migration (Phase 0, step 1).
expo-network is also required by the audit offline-caching plan.
30s staleTime for entity reads, gcTime >= staleTime, and a retry
predicate that skips 4xx so permission and validation errors surface
immediately instead of being retried.
makeRequest previously resolved a 403 as null, so a permission denial
and a valid empty response were indistinguishable once TanStack starts
caching resolved values as successful data. Introduces a typed
PermissionDeniedError carrying the permissionKey and status, and
converts the six call sites that depended on the old null-check
convention in the same commit: assets edit/create, accessories edit,
consumables checkout, and both audit-count fetches in
AuditDashboardCard (which hides the dashboard card on denial).
Wraps the app in QueryClientProvider with DevTools attached, and wires
TanStack's onlineManager to expo-network and focusManager to AppState
so refetchOnReconnect/refetchOnWindowFocus behave correctly on native,
where they're no-ops by default.
TanStack's documented screen-focus pattern: skips the initial mount via
firstTimeRef, then refetches stale active queries on subsequent focus.
Uses expo-router's useFocusEffect per this repo's existing convention.
assetKeys.detail/list and customFieldKeys.all give the detail, edit,
checkin, checkout, and list screens a shared, typo-proof vocabulary
for querying and invalidating the asset cache.
A reanimated pulsing placeholder plus an AssetDetailSkeleton
composition mirroring the real Section/DetailRow geometry, so the
cold-open layout doesn't collapse while the first asset query
resolves.
Splits the /hardware/{id} and /fields Promise.allSettled into two
independent useQuery calls with separate staleTime, so a /fields
denial no longer risks the asset render and both queries share cache
with the edit screen. Cached data renders instantly on repeat visits;
only a cold, cacheless open shows AssetDetailSkeleton instead of
blanking the whole screen on every edit-then-back navigation. Replaces
the useFocusEffect refetch-on-focus with useRefreshOnFocus, and wires
RefreshControl to refetch instead of a local loading/refreshing pair.
Hydrates from the same assetKeys.detail(id)/customFieldKeys.all cache
as the detail screen, so opening the editor after viewing an asset is
instant. The existing Burnt success/error toasts move into the
mutation's onSuccess/onError; on success the detail and list caches
are invalidated and navigation uses router.dismissTo instead of
router.replace so the detail screen below is reused rather than
replaced with a fresh, cache-cold instance.
create.jsx has no detail screen below it to dismiss to, so dismissTo
falls back to its documented replace behavior here — kept in step with
the other assets mutation handlers for consistency.
Moves the POST /hardware/{id}/checkin call to useMutation, invalidates
the asset detail and list caches on success, and swaps router.replace
for router.dismissTo so returning to the detail screen reuses its
cache instead of mounting a fresh, cache-cold instance.
Same shape as the checkin migration: POST /hardware/{id}/checkout as a
useMutation, invalidates the asset detail and list caches on success,
and uses router.dismissTo to return to the detail screen.
getNextPageParam continues the existing offset-25 scheme, and
placeholderData: keepPreviousData means changing search text or
filters no longer blanks the list while the new page loads. Deletes
the setData([]) calls that ran on every focus/search/filter change, so
scroll position and loaded pages now survive navigating to a detail
screen and back — replaced by useRefreshOnFocus for the focus-refetch
behavior those calls used to provide.
RefreshControl's refreshing prop was bound directly to isFetching/
isRefetching, so any background refetch (focus refetch, post-mutation
invalidation) triggered the native spinner and reserved its layout
space even though the user never pulled down. On iOS this can also
render as a frozen, non-animating spinner since the native control
never received an actual pull gesture. Track refresh state locally
and only flip it from the RefreshControl's own onRefresh handler.
Swap RN's Image for expo-image on the asset detail and list screens,
with a 200ms fade transition and an onLoadStart/onLoadEnd pair driving
a small ActivityIndicator overlay while each image loads. List rows
track their own load state independently so scrolling doesn't cross-
trigger neighboring spinners. cachePolicy is set explicitly to
memory-disk per the project's expo-image caching convention.
Wires a __DEV__-gated button that clears the TanStack Query cache and
expo-image's memory/disk caches in one tap, for testing cold-start and
loading-indicator behavior without reinstalling the app. Guards the
image-cache clear with a 5s timeout via Promise.race so a native-module
hiccup can never leave the button spinning indefinitely.
queryClient.refetchQueries()/invalidateQueries() reject their returned
promise when a refetch fails, unlike a passive useQuery observer which
stores the error in query state instead of throwing. useRefreshOnFocus
and the asset checkin/checkout/edit success handlers called these
without awaiting or catching them, so a failed background refresh
surfaced as an unhandled rejection (visible as a raw AxiosError in
RN's global handler) instead of failing silently like the rest of
these background-sync paths.
The asset detail, edit, checkin, and checkout screens hardcoded
paddingTop: insets.top + 44 as a fixed guess at the native header
height, with no contentInsetAdjustmentBehavior set. That guess drifts
after repeated navigation transitions, producing a growing gap between
the header and content. Switch to the convention used everywhere else
in the app: contentInsetAdjustmentBehavior="automatic" with
paddingTop: 0 on iOS (letting the OS compute the inset correctly) and
insets.top + 56 on Android.
@snipe

snipe commented Aug 27, 2026

Copy link
Copy Markdown
Member

I thought we were going to use CrowdIn's OTA language stuff?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants