[UI] Page Load Improvements (#12334)

* Parallel initial requests

* Remove debouncing

* Refactor lazy loading of desktop / mobile view

* Reduce initial waiting for loadable components

* Fix duplication of API calls

* Further reduce duplicate calls

* Combine user roles into /user/me/ endpoint

* lazy load global import drawer

* lazy load table in plugin context

* Patch ScanButton

* Added playwright tests for login

* Adjust thresholds
This commit is contained in:
Oliver 2026-07-10 18:08:25 +10:00 committed by GitHub
parent a38c3963f2
commit 83508f4b12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 711 additions and 157 deletions

View File

@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 518
INVENTREE_API_VERSION = 519
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/TODO
- Adds optional "roles" and "permissions" fields to the /user/me/ API endpoint, via the "?roles=true" query parameter
v518 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12341
- Enable import of internal part prices via the API

View File

@ -248,6 +248,24 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail):
"""
return None
@extend_schema(
parameters=[
OpenApiParameter(
name='roles',
type=bool,
location=OpenApiParameter.QUERY,
description='Include the roles and permissions associated with the current user in the response',
)
]
)
def get(self, request, *args, **kwargs):
"""Retrieve details for the current user.
Pass '?roles=true' to also include the user's roles and permissions
(previously only available via the separate '/user/me/roles/' endpoint).
"""
return super().get(request, *args, **kwargs)
class UserList(ListCreateAPI):
"""List endpoint for detail on all users.

View File

@ -78,37 +78,47 @@ class RoleSerializer(InvenTreeModelSerializer):
def get_roles(self, user: User) -> dict:
"""Roles associated with the user."""
roles = {}
# Cache the 'groups' queryset for the user
groups = prefetch_rule_sets(user)
for ruleset in RULESET_CHOICES:
role, _text = ruleset
permissions = []
for permission in RULESET_PERMISSIONS:
if check_user_role(user, role, permission, groups=groups):
permissions.append(permission)
if len(permissions) > 0:
roles[role] = permissions
else:
roles[role] = None # pragma: no cover
return roles
return get_user_roles(user)
def get_permissions(self, user: User) -> dict:
"""Permissions associated with the user."""
if user.is_superuser:
permissions = Permission.objects.all()
else:
permissions = Permission.objects.filter(
Q(user=user) | Q(group__user=user)
).distinct()
return get_user_permissions(user)
return generate_permission_dict(permissions)
def get_user_roles(user: User) -> dict:
"""Return a dict of the roles associated with the given user."""
roles = {}
# Cache the 'groups' queryset for the user
groups = prefetch_rule_sets(user)
for ruleset in RULESET_CHOICES:
role, _text = ruleset
permissions = []
for permission in RULESET_PERMISSIONS:
if check_user_role(user, role, permission, groups=groups):
permissions.append(permission)
if len(permissions) > 0:
roles[role] = permissions
else:
roles[role] = None # pragma: no cover
return roles
def get_user_permissions(user: User) -> dict:
"""Return a dict of the permissions associated with the given user."""
if user.is_superuser:
permissions = Permission.objects.all()
else:
permissions = Permission.objects.filter(
Q(user=user) | Q(group__user=user)
).distinct()
return generate_permission_dict(permissions)
def generate_permission_dict(permissions) -> dict:
@ -390,7 +400,7 @@ class UserSetPasswordSerializer(serializers.Serializer):
)
class MeUserSerializer(ExtendedUserSerializer):
class MeUserSerializer(FilterableSerializerMixin, ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""
class Meta(ExtendedUserSerializer.Meta):
@ -401,7 +411,11 @@ class MeUserSerializer(ExtendedUserSerializer):
"""
# Remove the 'group_ids' field, as this is not relevant for the 'me' endpoint
fields = [f for f in ExtendedUserSerializer.Meta.fields if f != 'group_ids']
fields = [
*(f for f in ExtendedUserSerializer.Meta.fields if f != 'group_ids'),
'roles',
'permissions',
]
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
@ -412,6 +426,31 @@ class MeUserSerializer(ExtendedUserSerializer):
profile = UserProfileSerializer(many=False, read_only=True)
# Roles and permissions are only computed (and included) when the
# request explicitly asks for them via '?roles=true' - they require
# extra queries, and most callers of this endpoint only want basic
# user details. Shares a filter_name so both come back together, since
# they were previously served as a single '/user/me/roles/' response.
roles = OptionalField(
serializer_class=serializers.SerializerMethodField,
serializer_kwargs={'read_only': True},
filter_name='roles',
)
permissions = OptionalField(
serializer_class=serializers.SerializerMethodField,
serializer_kwargs={'allow_null': True, 'read_only': True},
filter_name='roles',
)
def get_roles(self, user: User) -> dict:
"""Roles associated with the user."""
return get_user_roles(user)
def get_permissions(self, user: User) -> dict:
"""Permissions associated with the user."""
return get_user_permissions(user)
# Redefine the fields from ExtendedUserSerializer, to ensure they are marked as read-only
is_staff = serializers.BooleanField(
label=_('Staff'),

View File

@ -4,11 +4,19 @@ import { t } from '@lingui/core/macro';
import { ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconQrcode } from '@tabler/icons-react';
import BarcodeScanDialog, {
type BarcodeScanCallback,
type BarcodeScanSuccessCallback
import { Suspense, lazy, useState } from 'react';
import type {
BarcodeScanCallback,
BarcodeScanSuccessCallback
} from '../barcodes/BarcodeScanDialog';
// Lazy loaded: ScanButton is rendered unconditionally in the nav Header (and
// elsewhere), but the scan dialog itself is only ever needed once a user
// actually opens it - deferring the import until first open (rather than
// just lazy-loading the component, which would still fetch it on every
// render) avoids pulling its dependency tree into every page load.
const BarcodeScanDialog = lazy(() => import('../barcodes/BarcodeScanDialog'));
/**
* A button which opens the QR code scanner modal
*/
@ -24,6 +32,12 @@ export function ScanButton({
hotkey?: boolean;
}) {
const [opened, { open, close }] = useDisclosure(false);
const [everOpened, setEverOpened] = useState(false);
function handleOpen() {
setEverOpened(true);
open();
}
if (hotkey) {
useInvenTreeHotkeys([
@ -31,7 +45,7 @@ export function ScanButton({
'mod+Shift+B',
t`Open barcode scanner`,
() => {
open();
handleOpen();
}
]
]);
@ -42,19 +56,23 @@ export function ScanButton({
<Tooltip position='bottom-end' label={t`Scan Barcode`}>
<ActionIcon
aria-label={`barcode-scan-button-${modelType ?? 'any'}`}
onClick={open}
onClick={handleOpen}
variant='transparent'
>
<IconQrcode />
</ActionIcon>
</Tooltip>
<BarcodeScanDialog
opened={opened}
modelType={modelType}
callback={callback}
onClose={close}
onScanSuccess={onScanSuccess}
/>
{everOpened && (
<Suspense fallback={null}>
<BarcodeScanDialog
opened={opened}
modelType={modelType}
callback={callback}
onClose={close}
onScanSuccess={onScanSuccess}
/>
</Suspense>
)}
</>
);
}

View File

@ -1,5 +1,14 @@
import { lazy } from 'react';
import { Loadable } from '../../functions/loading';
import { useImporterState } from '../../states/ImporterState';
import ImporterDrawer from './ImporterDrawer';
// Lazy loaded: this pulls in InvenTreeTable (and its own sizeable
// dependency tree) via ImportDataSelector, but an import session is only
// ever open for a small fraction of page loads - the isOpen/sessionId
// check below runs first regardless, so nothing here is fetched at all
// unless the user actually opens the importer.
const ImporterDrawer = Loadable(lazy(() => import('./ImporterDrawer')));
export default function GlobalImporterDrawer() {
const isOpen = useImporterState((state) => state.isOpen);

View File

@ -1,5 +1,5 @@
import { useMantineColorScheme, useMantineTheme } from '@mantine/core';
import { useMemo } from 'react';
import { Suspense, lazy, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow';
import { api, queryClient } from '../../App';
@ -51,7 +51,18 @@ import { EditApiForm } from '../forms/ApiForm';
import { Thumbnail } from '../images/Thumbnail';
import { RenderInstance, RenderRemoteInstance } from '../render/Instance';
import { RenderInlineModel } from '../render/Instance';
import { InvenTreeTableInternal } from '../tables/InvenTreeTable';
// Lazy loaded: useInvenTreeContext is used by the always-mounted nav Layout
// to build the context handed to plugins, but tables.renderTable is only
// ever actually called by a plugin that chooses to render a table - which
// is rare. Loading InvenTreeTable's (sizeable) module here unconditionally
// would mean every page load pays for it regardless of whether any plugin
// uses it.
const InvenTreeTableInternal = lazy(() =>
import('../tables/InvenTreeTable').then((m) => ({
default: m.InvenTreeTableInternal
}))
);
export const useInvenTreeContext = () => {
const [locale, host] = useLocalState(useShallow((s) => [s.language, s.host]));
@ -99,10 +110,12 @@ export const useInvenTreeContext = () => {
},
tables: {
renderTable: (props: InvenTreeTableRenderProps<any>) => (
<InvenTreeTableInternal
{...props}
showContextMenu={showContextMenu}
/>
<Suspense fallback={null}>
<InvenTreeTableInternal
{...props}
showContextMenu={showContextMenu}
/>
</Suspense>
)
},
forms: {

View File

@ -6,6 +6,7 @@ import { type JSX, useEffect, useRef, useState } from 'react';
import { useStoredTableState } from '@lib/states/StoredTableState';
import { useShallow } from 'zustand/react/shallow';
import { api } from '../App';
import { markLocaleReady } from '../functions/localeReady';
import { useLocalState } from '../states/LocalState';
import { useServerApiState } from '../states/ServerApiState';
import { fetchGlobalStates } from '../states/states';
@ -140,8 +141,11 @@ export function LanguageContext({
// Update default Accept-Language headers
api.defaults.headers.common['Accept-Language'] = new_locales;
// Reload server state (and refresh status codes)
fetchGlobalStates();
// Reload server state (and refresh status codes). Forced: the
// Accept-Language header actually changed (initial set, or a real
// locale change), so this must not be skipped by the "already
// fetched" guard even if another caller already fetched once.
fetchGlobalStates(true);
// Clear out cached table column names
useStoredTableState.getState().clearTableColumnNames();
@ -195,6 +199,7 @@ export async function activateLocale(locale: string | null) {
const { messages } = await import(`../locales/${localeDir}/messages.ts`);
i18n.load(locale, messages);
i18n.activate(locale);
markLocaleReady();
} catch (err) {
console.error(`Failed to load locale ${locale}:`, err);
}

View File

@ -15,7 +15,7 @@ import { api, setApiDefaults } from '../App';
import { useLocalState } from '../states/LocalState';
import { useServerApiState } from '../states/ServerApiState';
import { useUserState } from '../states/UserState';
import { fetchGlobalStates } from '../states/states';
import { fetchGlobalStates, resetGlobalStatesFetched } from '../states/states';
import { showLoginNotification } from './notifications';
import { generateUrl } from './urls';
@ -171,7 +171,7 @@ export async function doBasicLogin(
// we are successfully logged in - gather required states for app
if (loginDone) {
await fetchUserState();
await fetchGlobalStates();
await fetchGlobalStates(true);
observeProfile();
} else if (!success) {
clearUserState();
@ -240,6 +240,7 @@ export const doLogout = async (navigate: NavigateFunction) => {
clearUserState();
clearCsrfCookie();
setAuthContext(undefined);
resetGlobalStatesFetched();
navigate('/login');
};
@ -455,6 +456,10 @@ export const checkLoginState = async (
MfaSetupOk(navigate).then(async (isOk) => {
if (isOk) {
observeProfile();
// Not forced: this runs on every page load's auth check, and
// LanguageContext's own locale-activation effect (which always
// runs first, since it gates rendering of this component's whole
// route tree) will typically have already triggered this fetch.
await fetchGlobalStates();
followRedirect(navigate, redirect);
@ -505,7 +510,7 @@ function handleSuccessFullAuth(
if (isOk) {
await fetchUserState();
observeProfile();
await fetchGlobalStates();
await fetchGlobalStates(true);
if (location !== undefined) {
followRedirect(navigate, location?.state);

View File

@ -1,5 +1,11 @@
import { Center, Loader, MantineProvider, Stack } from '@mantine/core';
import { type JSX, Suspense } from 'react';
import {
type ComponentType,
type JSX,
Suspense,
useEffect,
useState
} from 'react';
import { colorSchema } from '../contexts/colorSchema';
import { theme } from '../theme';
@ -38,3 +44,56 @@ export function LoadingItem({ item }: Readonly<{ item: any }>): JSX.Element {
const Itm = Loadable(item);
return <Itm />;
}
/**
* Like Loadable, but never suspends: the import is resolved into plain
* state instead of thrown as a Suspense promise, so React.lazy's commit-
* delay heuristic never kicks in (it can otherwise hold the first render -
* and everything below it - back by several hundred ms even once the chunk
* is cached).
*
* The import itself is NOT started at module-load: some chunks evaluate
* top-level i18n macros, which throws if that happens before the active
* locale is set. It starts on mount (same as React.lazy would), or earlier
* via the returned component's `.preload()`, which callers can invoke once
* they know it's safe to (e.g. once the locale is ready).
*
* Only use this for components that are needed on (or immediately after)
* initial load; for genuinely route-specific pages, prefer Loadable/lazy so
* the chunk isn't fetched until it's needed.
*/
export function EagerLoadable(
importFn: () => Promise<{ default: ComponentType<any> }>
): ComponentType<any> & { preload: () => Promise<ComponentType<any>> } {
let componentPromise: Promise<ComponentType<any>> | null = null;
function preload() {
if (!componentPromise) {
componentPromise = importFn().then((m) => m.default);
}
return componentPromise;
}
function EagerLoaded(props: JSX.IntrinsicAttributes) {
const [Component, setComponent] = useState<ComponentType<any> | null>(null);
useEffect(() => {
let cancelled = false;
preload().then((C) => {
if (!cancelled) setComponent(() => C);
});
return () => {
cancelled = true;
};
}, []);
if (!Component) {
return null;
}
return <Component {...props} />;
}
EagerLoaded.preload = preload;
return EagerLoaded;
}

View File

@ -0,0 +1,24 @@
/**
* Tiny pub/sub so code outside the React tree (e.g. router.tsx) can safely
* prefetch chunks that evaluate top-level i18n macros, without needing to
* know how/when LanguageContext finishes activating the locale.
*/
type Listener = () => void;
let ready = false;
const listeners = new Set<Listener>();
export function markLocaleReady() {
if (ready) return;
ready = true;
for (const listener of listeners) listener();
listeners.clear();
}
export function onLocaleReady(listener: Listener) {
if (ready) {
listener();
} else {
listeners.add(listener);
}
}

View File

@ -1,5 +1,4 @@
import { t } from '@lingui/core/macro';
import { useDebouncedCallback } from '@mantine/hooks';
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
@ -9,10 +8,9 @@ import { Wrapper } from './Layout';
export default function Logged_In() {
const navigate = useNavigate();
const location = useLocation();
const checkLoginStateDebounced = useDebouncedCallback(checkLoginState, 300);
useEffect(() => {
checkLoginStateDebounced(navigate, location?.state);
checkLoginState(navigate, location?.state);
}, [navigate]);
return (

View File

@ -21,6 +21,7 @@ import {
} from '../../functions/auth';
import { useLocalState } from '../../states/LocalState';
import { useServerApiState } from '../../states/ServerApiState';
import { useUserState } from '../../states/UserState';
import { Wrapper } from './Layout';
export default function Login() {
@ -45,6 +46,9 @@ export default function Login() {
state.registration_enabled
])
);
const [loginChecked] = useUserState(
useShallow((state) => [state.login_checked])
);
const any_reg_enabled = registration_enabled() || sso_registration() || false;
const LoginMessage = useMemo(() => {
@ -64,22 +68,26 @@ export default function Login() {
}, [server.customize]);
// Data manipulation functions
function ChangeHost(newHost: string | null): void {
// `force` defaults to true since this is normally a genuine host change
function ChangeHost(newHost: string | null, force = true): void {
if (newHost === null) return;
setHost(hostList[newHost]?.host, newHost);
setApiDefaults();
const traceid = setTraceId();
fetchServerApiState();
fetchServerApiState(force);
removeTraceId(traceid);
}
// Set default host to localhost if no host is selected
useEffect(() => {
if (hostKey === '') {
ChangeHost(defaultHostKey);
ChangeHost(defaultHostKey, false);
}
checkLoginState(navigate, location?.state, true);
// Only check here if a check hasn't already happened this session
if (!loginChecked) {
checkLoginState(navigate, location?.state, true);
}
// check if we got login params (login and password)
if (searchParams.has('login') && searchParams.has('password')) {

View File

@ -1,18 +1,18 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Loadable } from './functions/loading';
import { EagerLoadable, Loadable } from './functions/loading';
import { onLocaleReady } from './functions/localeReady';
// Lazy loaded pages
export const LayoutComponent = Loadable(
lazy(() => import('./components/nav/Layout')),
true,
true
// These two are mutually exclusive and one of them is always needed
// immediately on initial load, so they're loaded eagerly rather than via
// Loadable/lazy - see EagerLoadable for why.
export const LayoutComponent = EagerLoadable(
() => import('./components/nav/Layout')
);
export const LoginLayoutComponent = Loadable(
lazy(() => import('./pages/Auth/Layout')),
true,
true
export const LoginLayoutComponent = EagerLoadable(
() => import('./pages/Auth/Layout')
);
export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
@ -127,11 +127,21 @@ export const NotFound = Loadable(
// Auth
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
export const LoggedIn = Loadable(
lazy(() => import('./pages/Auth/LoggedIn')),
true,
true
);
// LoggedIn is the auth-check gateway hit by every fresh, unauthenticated
// page load (redirected to from ProtectedRoute) - load it eagerly too.
export const LoggedIn = EagerLoadable(() => import('./pages/Auth/LoggedIn'));
// These three are all needed within the first render pass or two of any
// fresh page load, so start fetching them as soon as it's safe to (i.e. as
// soon as the active locale is set) rather than waiting for each to mount
// in turn - mounting only happens after the previous one in the chain has
// already rendered and redirected, so waiting for mount compounds several
// round trips of otherwise-avoidable latency.
onLocaleReady(() => {
LayoutComponent.preload();
LoginLayoutComponent.preload();
LoggedIn.preload();
});
export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
export const Register = Loadable(lazy(() => import('./pages/Auth/Register')));
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));

View File

@ -11,7 +11,7 @@ import type { ServerAPIProps } from './states';
interface ServerApiStateProps {
server: ServerAPIProps;
setServer: (newServer: ServerAPIProps) => void;
fetchServerApiState: () => Promise<void>;
fetchServerApiState: (force?: boolean) => Promise<void>;
auth_config?: AuthConfig;
auth_context?: AuthContext;
setAuthContext: (auth_context: AuthContext | undefined) => void;
@ -31,13 +31,24 @@ function get_server_setting(val: any) {
return val;
}
let pendingServerApiFetch: Promise<void> | null = null;
let serverApiFetched = false;
export const useServerApiState = create<ServerApiStateProps>()(
persist(
(set, get) => ({
server: emptyServerAPI,
setServer: (newServer: ServerAPIProps) => set({ server: newServer }),
fetchServerApiState: async () => {
await Promise.all([
fetchServerApiState: async (force = false) => {
if (pendingServerApiFetch && !force) {
return pendingServerApiFetch;
}
if (serverApiFetched && !force) {
return;
}
pendingServerApiFetch = Promise.all([
// Fetch server data
api
.get(apiUrl(ApiEndpoints.api_server_info))
@ -59,7 +70,14 @@ export const useServerApiState = create<ServerApiStateProps>()(
.catch(() => {
console.error('ERR: Error fetching SSO information');
})
]);
]).then(() => {});
try {
await pendingServerApiFetch;
serverApiFetched = true;
} finally {
pendingServerApiFetch = null;
}
},
auth_config: undefined,
auth_context: undefined,

View File

@ -72,59 +72,36 @@ export const useUserState = create<UserStateProps>((set, get) => ({
return;
}
// Fetch user data
await api
// Fetch user data along with role/permission data in a single request -
// the '?roles=true' param asks the API to include the same role and
// permission data that used to require a separate request to
// user_me_roles.
const response = await api
.get(apiUrl(ApiEndpoints.user_me), {
params: { roles: true },
timeout: 2000
})
.then((response) => {
if (response.status == 200) {
const user: UserProps = {
pk: response.data.pk,
first_name: response.data?.first_name ?? '',
last_name: response.data?.last_name ?? '',
email: response.data.email,
username: response.data.username,
groups: response.data.groups,
profile: response.data.profile
};
get().setUser(user);
// profile info
} else {
get().clearUserState();
}
})
.catch(() => {
get().clearUserState();
});
.catch(() => undefined);
if (!get().isLoggedIn()) {
if (response?.status !== 200) {
get().clearUserState();
return;
}
// Fetch role data
await api
.get(apiUrl(ApiEndpoints.user_me_roles))
.then((response) => {
if (response.status == 200) {
const user: UserProps = get().user as UserProps;
// Update user with role data
if (user) {
user.roles = response.data?.roles ?? {};
user.permissions = response.data?.permissions ?? {};
user.is_staff = response.data?.is_staff ?? false;
user.is_superuser = response.data?.is_superuser ?? false;
get().setUser(user);
}
} else {
get().clearUserState();
}
})
.catch((_error) => {
console.error('ERR: Error fetching user roles');
get().clearUserState();
});
const user: UserProps = {
pk: response.data.pk,
first_name: response.data?.first_name ?? '',
last_name: response.data?.last_name ?? '',
email: response.data.email,
username: response.data.username,
groups: response.data.groups,
profile: response.data.profile,
roles: response.data?.roles ?? {},
permissions: response.data?.permissions ?? {},
is_staff: response.data?.is_staff ?? false,
is_superuser: response.data?.is_superuser ?? false
};
get().setUser(user);
},
isAuthed: () => {
return get().is_authed;

View File

@ -41,25 +41,62 @@ export interface ServerAPIProps {
};
}
let pendingGlobalStatesFetch: Promise<void> | null = null;
let globalStatesFetched = false;
/*
* Refetch all global state information.
* Necessary on login, or if locale is changed.
*
* Calls are deduplicated: a call made while a fetch is already in flight
* reuses that fetch instead of starting another, and once a fetch has
* completed successfully, later calls are skipped entirely unless `force`
* is set. Pass `force` when the caller already knows the data needs to be
* current (an actual login, or a genuine locale change) - other callers
* (e.g. a page-load auth check that may run after the locale has already
* triggered a fetch) can rely on the default to avoid redundant requests.
*/
export async function fetchGlobalStates() {
export async function fetchGlobalStates(force = false) {
const { isLoggedIn } = useUserState.getState();
if (!isLoggedIn()) {
return;
}
setApiDefaults();
const traceId = setTraceId();
await Promise.all([
useServerApiState.getState().fetchServerApiState(),
useUserSettingsState.getState().fetchSettings(),
useGlobalSettingsState.getState().fetchSettings(),
useGlobalStatusState.getState().fetchStatus(),
useIconState.getState().fetchIcons()
]);
removeTraceId(traceId);
if (pendingGlobalStatesFetch) {
return pendingGlobalStatesFetch;
}
if (globalStatesFetched && !force) {
return;
}
pendingGlobalStatesFetch = (async () => {
setApiDefaults();
const traceId = setTraceId();
await Promise.all([
useServerApiState.getState().fetchServerApiState(),
useUserSettingsState.getState().fetchSettings(),
useGlobalSettingsState.getState().fetchSettings(),
useGlobalStatusState.getState().fetchStatus(),
useIconState.getState().fetchIcons()
]);
removeTraceId(traceId);
globalStatesFetched = true;
})();
try {
await pendingGlobalStatesFetch;
} finally {
pendingGlobalStatesFetch = null;
}
}
/*
* Reset the "already fetched" guard on fetchGlobalStates, so that the next
* call performs a real fetch even without `force`. Call this on logout so a
* subsequent login within the same page session (no full reload) refetches.
*/
export function resetGlobalStatesFetched() {
globalStatesFetched = false;
}

View File

@ -1,10 +1,9 @@
import '@mantine/core/styles.css';
import { useViewportSize } from '@mantine/hooks';
import { lazy, useEffect } from 'react';
import { type ComponentType, useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { setApiDefaults } from '../App';
import { Loadable } from '../functions/loading';
import { useLocalState } from '../states/LocalState';
function checkMobile() {
@ -13,22 +12,22 @@ function checkMobile() {
return false;
}
const MobileAppView = Loadable(
lazy(() => import('./MobileAppView')),
true,
true
);
const DesktopAppView = Loadable(
lazy(() => import('./DesktopAppView')),
true,
true
);
// Import both views eagerly (outside React.lazy/Suspense): a lazy component
// always suspends on its first render, and React's Suspense commit-delay
// heuristic can then hold that first commit - and every effect beneath it,
// including locale and layout loading - back by several hundred ms, even
// though the underlying chunk is already cached by the time it's needed.
const desktopViewPromise = import('./DesktopAppView').then((m) => m.default);
const mobileViewPromise = import('./MobileAppView').then((m) => m.default);
// Main App
export default function MainView() {
const [allowMobile] = useLocalState(
useShallow((state) => [state.allowMobile])
);
const [DesktopView, setDesktopView] = useState<ComponentType | null>(null);
const [MobileView, setMobileView] = useState<ComponentType | null>(null);
// Set initial login status
useEffect(() => {
try {
@ -39,15 +38,22 @@ export default function MainView() {
}
}, []);
useEffect(() => {
desktopViewPromise.then((Component) => setDesktopView(() => Component));
mobileViewPromise.then((Component) => setMobileView(() => Component));
}, []);
// Check if mobile
if (
const isMobile =
!allowMobile &&
window.INVENTREE_SETTINGS.mobile_mode !== 'allow-always' &&
checkMobile()
) {
return <MobileAppView />;
checkMobile();
const View = isMobile ? MobileView : DesktopView;
if (!View) {
return null;
}
// Main App component
return <DesktopAppView />;
return <View />;
}

View File

@ -38,3 +38,8 @@ export const noaccessuser: UserType = {
username: 'noaccess',
testcred: 'youshallnotpass'
};
export const engineeruser: UserType = {
username: 'engineer',
testcred: 'partsonly'
};

View File

@ -1,9 +1,61 @@
import type { Page } from '@playwright/test';
import { TOTP } from 'otpauth';
import { expect, test } from './baseFixtures.js';
import { logoutUrl, noaccessuser } from './defaults.js';
import { engineeruser, logoutUrl, noaccessuser } from './defaults.js';
import { navigate, openDetailAction } from './helpers.js';
import { doLogin } from './login.js';
import { TOTP } from 'otpauth';
const stripQueryAndHash = (url: string): string => {
try {
const parsed = new URL(url);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return url.split('?')[0].split('#')[0];
}
};
const isScriptOrStyle = (resourceType: string): boolean => {
return resourceType === 'script' || resourceType === 'stylesheet';
};
const isCriticalBundle = (url: string): boolean => {
if (!/\.(js|css)$/i.test(url)) {
return false;
}
if (/\.map$/i.test(url)) {
return false;
}
if (/(@vite\/client|hot-update)/i.test(url)) {
return false;
}
return /\/(assets|static)\//i.test(url);
};
const loginAndMeasure = async (page: Page): Promise<number> => {
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
await page.getByLabel('login-username').fill(noaccessuser.username);
await page.getByLabel('login-password').fill(noaccessuser.testcred);
const start = Date.now();
await page.getByRole('button', { name: 'Log In' }).click();
await page.getByRole('link', { name: 'Dashboard' }).waitFor();
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
await page.waitForURL(/\/web(\/home)?/);
await page.waitForLoadState('networkidle');
// Ensure dashboard has completely loaded
await page.getByText('No Widgets Selected').waitFor();
await page.getByRole('button', { name: 'Norman Nothington' }).waitFor();
await page.waitForLoadState('networkidle');
return Date.now() - start;
};
/**
* Test various types of login failure
@ -90,6 +142,256 @@ test('Login - Failures', async ({ page }) => {
}
});
// Check that page load times do not exceed thresholds for cold/warm/hot login scenarios
test('Login - Cold vs Warm vs Hot Load', async ({ page }) => {
// Ensure a fresh state for the cold login measurement.
await page.context().clearCookies();
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
// Page load threshold values
// Note: Vite server in dev mode is significantly slower than production build
const COLD_MS_THRESHOLD: number = 5000;
const WARM_MS_THRESHOLD: number = 4000;
const HOT_MS_THRESHOLD: number = 3000;
const coldMs = await loginAndMeasure(page);
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
const warmMs = await loginAndMeasure(page);
console.log('Cold MS:', coldMs, 'Warm MS:', warmMs);
expect(coldMs).toBeLessThan(COLD_MS_THRESHOLD);
expect(warmMs).toBeLessThan(WARM_MS_THRESHOLD);
// Perform a "hot" reload of the dashboard page, which should be faster than the warm login.
const start = Date.now();
await page.reload();
// Ensure dashboard has completely loaded
await page.getByText('No Widgets Selected').waitFor();
await page.getByRole('button', { name: 'Norman Nothington' }).waitFor();
await page.waitForLoadState('networkidle');
const hotMs = Date.now() - start;
console.log('Hot MS:', hotMs);
expect(hotMs).toBeLessThan(HOT_MS_THRESHOLD);
});
// Check for JS/CSS request failures and duplicate critical bundles during login boot
test('Login - JS/CSS Boot Checks', async ({ page }) => {
const failedResources: string[] = [];
const criticalResourceCounts = new Map<string, number>();
page.on('requestfailed', (request) => {
if (!isScriptOrStyle(request.resourceType())) {
return;
}
failedResources.push(
`${request.resourceType()} ${request.url()} (${request.failure()?.errorText ?? 'request failed'})`
);
});
page.on('requestfinished', async (request) => {
if (!isScriptOrStyle(request.resourceType())) {
return;
}
const response = await request.response();
if (!response) {
return;
}
const status = response.status();
if (status >= 400) {
failedResources.push(
`${request.resourceType()} ${request.url()} (HTTP ${status})`
);
}
const normalizedUrl = stripQueryAndHash(request.url());
if (isCriticalBundle(normalizedUrl)) {
const count = criticalResourceCounts.get(normalizedUrl) ?? 0;
criticalResourceCounts.set(normalizedUrl, count + 1);
}
});
await loginAndMeasure(page);
const duplicateCriticalBundles = [...criticalResourceCounts.entries()]
.filter(([, count]) => count > 1)
.map(([url, count]) => `${url} x${count}`);
expect(
failedResources,
`JS/CSS failures during login boot:\n${failedResources.join('\n')}`
).toEqual([]);
expect(
duplicateCriticalBundles,
`Duplicate critical bundles during login boot:\n${duplicateCriticalBundles.join('\n')}`
).toEqual([]);
});
// Check page redirect after login
test('Login - Redirect on Login', async ({ page }) => {
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
await navigate(page, 'settings/user/account', { waitUntil: 'load' });
await page.waitForURL('**/web/login');
await page.getByLabel('login-username').fill(engineeruser.username);
await page.getByLabel('login-password').fill(engineeruser.testcred);
await page.getByRole('button', { name: 'Log In' }).click();
await page.waitForURL('**/web/settings/user/account');
await page.getByRole('button', { name: 'action-menu-account' }).waitFor();
await page.getByRole('button', { name: 'Robert Shuruncle' }).waitFor();
});
// Test that login session persists across page reload
test('Login - Session Persistence', async ({ page }) => {
await doLogin(page, {
user: engineeruser
});
await page.getByText('Use the menu to add widgets').waitFor();
await page.reload();
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
// Once we logout, the user session has been invalidated
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
await page.goBack();
await page.waitForURL('**/web/login');
await page.getByLabel('login-username').waitFor();
});
// Test login session with forced network errors
test('Login - Network Errors & Retry', async ({ page }) => {
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
await page.getByLabel('login-username').fill(engineeruser.username);
await page.getByLabel('login-password').fill(engineeruser.testcred);
const loginEndpoint = /auth\/login/;
await page.route(loginEndpoint, (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Simulated server failure' })
});
});
const loginButton = page.getByRole('button', { name: 'Log In' });
await loginButton.click();
await page.getByText('Login failed (500)').waitFor();
await page.getByText('Simulated server failure').waitFor();
await expect(loginButton).toBeEnabled();
await page.unroute(loginEndpoint);
await loginButton.click();
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
await page.getByLabel('login-username').fill(noaccessuser.username);
await page.getByLabel('login-password').fill(noaccessuser.testcred);
await page.route(loginEndpoint, (route) => {
route.abort('internetdisconnected');
});
await loginButton.click();
await page.getByText('Login failed').first().waitFor();
await page.getByText('No response from server.').waitFor();
await expect(loginButton).toBeEnabled();
await page.getByLabel('login-username').waitFor();
await page.unroute(loginEndpoint);
await loginButton.click();
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
});
// Check for exposed tokens or cookies after login, and ensure session cookie is secure
test('Login - Security Regression Checks', async ({ page }) => {
await doLogin(page, {
user: noaccessuser
});
const url = page.url();
expect(url).not.toMatch(/[?&](token|access_token|refresh_token|jwt|auth)=/i);
const storageData = await page.evaluate(() => {
return {
localStorageEntries: Object.entries(localStorage),
sessionStorageEntries: Object.entries(sessionStorage)
};
});
const suspiciousStorageEntries = [
...storageData.localStorageEntries,
...storageData.sessionStorageEntries
].filter(([key, value]) => {
const combined = `${key} ${value}`;
return (
/(access_token|refresh_token|jwt|bearer)/i.test(combined) ||
/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)
);
});
expect(suspiciousStorageEntries).toEqual([]);
const cookies = await page.context().cookies();
const sessionCookie = cookies.find((cookie) => cookie.name === 'sessionid');
expect(sessionCookie).toBeDefined();
expect(sessionCookie?.httpOnly).toBeTruthy();
expect(['Lax', 'None', 'Strict']).toContain(sessionCookie?.sameSite ?? 'Lax');
});
// Check keyboard navigation of the login screen
test('Login - Keyboard Focus', async ({ page }) => {
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
const username = page.getByLabel('login-username');
const password = page.getByLabel('login-password');
await expect(username).toBeVisible();
await expect(password).toBeVisible();
await username.focus();
await page.keyboard.type(noaccessuser.username);
await page.keyboard.press('Tab');
await expect(password).toBeFocused();
await page.keyboard.type(noaccessuser.testcred);
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
await page.getByRole('button', { name: 'Log In' }).click();
await expect(page.getByLabel('login-username')).toBeFocused();
});
test('Login - Change Password', async ({ page }) => {
await doLogin(page, {
user: noaccessuser