feat(frontend): make webv2 the default through call sites

Select webv2 for launches, frontend tooling, wheels, containers and dependency installation while preserving both source paths.

Retain explicit legacy fallback, shared API artifact ownership and deployment-scoped browser worker cleanup.
This commit is contained in:
Josh Corbett
2026-09-29 14:19:20 -06:00
parent 5307d75274
commit b50e0b1403
28 changed files with 409 additions and 113 deletions
+1
View File
@@ -28,3 +28,4 @@
/invokeai/frontend/merge @lstein @blessedcoolant
/invokeai/frontend/training @lstein @blessedcoolant
/invokeai/frontend/web @blessedcoolant @lstein @dunkeroni @Pfannkuchensack @jpphoto
/invokeai/frontend/webv2 @blessedcoolant @lstein @dunkeroni @Pfannkuchensack @jpphoto
@@ -4,14 +4,14 @@ inputs:
working-directory:
description: Directory to install dependencies in
required: false
default: invokeai/frontend/web
default: invokeai/frontend/webv2
runs:
using: 'composite'
steps:
- name: setup node 22
uses: actions/setup-node@v6
with:
node-version: '22'
node-version-file: '.nvmrc'
- name: setup pnpm
uses: pnpm/action-setup@v6
+4 -1
View File
@@ -11,6 +11,7 @@ python:
- all-globs-to-any-file:
- 'invokeai/**'
- '!invokeai/frontend/web/**'
- '!invokeai/frontend/webv2/**'
python-tests:
- changed-files:
@@ -56,4 +57,6 @@ frontend-deps:
frontend:
- changed-files:
- any-glob-to-any-file: 'invokeai/frontend/web/**'
- any-glob-to-any-file:
- 'invokeai/frontend/web/**'
- 'invokeai/frontend/webv2/**'
+4
View File
@@ -62,12 +62,15 @@ jobs:
files_yaml: |
frontend:
- '.github/workflows/frontend-checks.yml'
- '.nvmrc'
- '.github/actions/install-frontend-deps/**'
- 'invokeai/frontend/web/**'
- name: install dependencies
if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }}
uses: ./.github/actions/install-frontend-deps
with:
working-directory: invokeai/frontend/web
- name: tsc
if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }}
@@ -111,6 +114,7 @@ jobs:
files_yaml: |
webv2:
- '.github/workflows/frontend-checks.yml'
- '.nvmrc'
- '.github/actions/install-frontend-deps/**'
- 'invokeai/frontend/webv2/**'
+4
View File
@@ -62,12 +62,15 @@ jobs:
files_yaml: |
frontend:
- '.github/workflows/frontend-tests.yml'
- '.nvmrc'
- '.github/actions/install-frontend-deps/**'
- 'invokeai/frontend/web/**'
- name: install dependencies
if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }}
uses: ./.github/actions/install-frontend-deps
with:
working-directory: invokeai/frontend/web
- name: vitest
if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }}
@@ -91,6 +94,7 @@ jobs:
files_yaml: |
webv2:
- '.github/workflows/frontend-tests.yml'
- '.nvmrc'
- '.github/actions/install-frontend-deps/**'
- 'invokeai/frontend/webv2/**'
+4
View File
@@ -79,6 +79,8 @@ jobs:
src:
- 'pyproject.toml'
- '.github/workflows/openapi-checks.yml'
- '.nvmrc'
- '.github/actions/install-frontend-deps/**'
- 'scripts/generate_openapi_schema.py'
- 'invokeai/**'
# The schema is generated from Python and compared against legacy web's artifacts.
@@ -107,6 +109,8 @@ jobs:
- name: install frontend dependencies
if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
uses: ./.github/actions/install-frontend-deps
with:
working-directory: invokeai/frontend/web
- name: copy schema
if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+5 -1
View File
@@ -79,6 +79,8 @@ jobs:
src:
- 'pyproject.toml'
- '.github/workflows/typegen-checks.yml'
- '.nvmrc'
- '.github/actions/install-frontend-deps/**'
- 'scripts/generate_openapi_schema.py'
- 'invokeai/**'
# The schema is generated from Python and compared against legacy web's artifacts.
@@ -107,6 +109,8 @@ jobs:
- name: install frontend dependencies
if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
uses: ./.github/actions/install-frontend-deps
with:
working-directory: invokeai/frontend/web
- name: copy schema
if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
@@ -115,7 +119,7 @@ jobs:
- name: generate schema
if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
run: cd invokeai/frontend/web && uv run ../../../scripts/generate_openapi_schema.py | pnpm typegen
run: set -o pipefail; cd invokeai/frontend/web && uv run ../../../scripts/generate_openapi_schema.py | pnpm typegen
shell: bash
- name: compare files
+1 -1
View File
@@ -19,7 +19,7 @@ Before editing, read `AGENTS.md` along each target path; scoped rules supplement
| CI and contribution tooling | [.github/AGENTS.md](.github/AGENTS.md) |
| Documentation | `docs/README.md` and `docs/package.json`; keep documentation about the implemented product current |
Ordinary frontend work targets **webv2**, served with `--webv2`; default launches select legacy web. Verify build/launch targets for UI investigations. Legacy web owns generated OpenAPI/type artifacts.
Ordinary frontend work targets **webv2**, served by default; `--webv2` remains a compatibility alias and `--web-legacy` selects legacy web. Verify build/launch targets for UI investigations. Legacy web owns generated OpenAPI/type artifacts.
## Engineering standard
+29 -25
View File
@@ -1,6 +1,8 @@
# simple Makefile with scripts that are otherwise hard to remember
# to use, run from the repo root `make <command>`
SHELL := /bin/bash
default: help
help:
@@ -13,11 +15,11 @@ help:
@echo "test Run the unit tests."
@echo "frontend-install Install the pnpm modules needed for the frontend"
@echo "frontend-build Build the frontend for localhost:9090"
@echo "frontendv2-install Install the pnpm modules needed for the webv2 frontend"
@echo "frontendv2-build Build the webv2 frontend for localhost:9090"
@echo "frontend-legacy-install Install legacy frontend dependencies"
@echo "frontend-legacy-build Build the legacy frontend for --web-legacy"
@echo "frontend-test Run the frontend test suite once"
@echo "frontend-dev Run the frontend in developer mode on localhost:5173"
@echo "frontendv2-dev Run the webv2 frontend in developer mode on localhost:5173"
@echo "frontend-legacy-dev Run the legacy frontend development server"
@echo "frontend-openapi Generate the OpenAPI schema"
@echo "frontend-typegen Generate types for the frontend from the OpenAPI schema"
@echo "frontend-lint Run frontend checks and fixable lint/format steps"
@@ -53,33 +55,39 @@ test:
# Install the pnpm modules needed for the front end
frontend-install:
rm -rf invokeai/frontend/web/node_modules
cd invokeai/frontend/web && pnpm install
# Build the frontend
frontend-build:
cd invokeai/frontend/web && pnpm build
# Install the pnpm modules needed for the webv2 front end
frontendv2-install:
rm -rf invokeai/frontend/webv2/node_modules
cd invokeai/frontend/webv2 && pnpm install
# Build the webv2 frontend
frontendv2-build:
# Build the frontend
frontend-build:
cd invokeai/frontend/webv2 && pnpm build
# Compatibility aliases and explicit legacy commands
frontendv2-install: frontend-install
frontend-legacy-install:
rm -rf invokeai/frontend/web/node_modules
cd invokeai/frontend/web && pnpm install
# Build the legacy frontend
frontendv2-build: frontend-build
frontend-legacy-build:
cd invokeai/frontend/web && pnpm build
# Run the frontend test suite once
frontend-test:
cd invokeai/frontend/web && pnpm run test:run
cd invokeai/frontend/webv2 && pnpm test
# Run the frontend in dev mode
frontend-dev:
cd invokeai/frontend/web && pnpm dev
cd invokeai/frontend/webv2 && pnpm dev
# Run the webv2 frontend in dev mode
frontendv2-dev:
cd invokeai/frontend/webv2 && pnpm run dev
# Run the legacy frontend in dev mode
frontendv2-dev: frontend-dev
frontend-legacy-dev:
cd invokeai/frontend/web && pnpm run dev
# Generate the OpenAPI Schema for the app
frontend-openapi:
@@ -88,14 +96,10 @@ frontend-openapi:
pnpm prettier --write openapi.json
frontend-typegen:
cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen
set -o pipefail; cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen
frontend-lint:
cd invokeai/frontend/web/src && \
pnpm lint:tsc && \
pnpm lint:dpdm && \
pnpm lint:eslint --fix && \
pnpm lint:prettier --write
pnpm -C invokeai/frontend/webv2 lint
# Tag the release
wheel:
+10 -2
View File
@@ -8,10 +8,17 @@ ENV PATH="$PNPM_HOME:$PATH"
RUN corepack use [email protected] && corepack enable
WORKDIR /build
COPY invokeai/frontend/webv2/ ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm exec vite build
FROM web-builder AS legacy-web-builder
WORKDIR /legacy-build
COPY invokeai/frontend/web/ ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN npx vite build
RUN pnpm exec vite build
## Backend ---------------------------------------
@@ -95,7 +102,8 @@ ENTRYPOINT ["/opt/invokeai/docker-entrypoint.sh"]
CMD ["invokeai-web"]
# --link requires buldkit w/ dockerfile syntax 1.4, does not work with podman
COPY --link --from=web-builder /build/dist ${INVOKEAI_SRC}/invokeai/frontend/web/dist
COPY --link --from=web-builder /build/dist ${INVOKEAI_SRC}/invokeai/frontend/webv2/dist
COPY --link --from=legacy-web-builder /legacy-build/dist ${INVOKEAI_SRC}/invokeai/frontend/web/dist
# add sources last to minimize image changes on code changes
COPY invokeai ${INVOKEAI_SRC}/invokeai
@@ -7,28 +7,32 @@ Invoke's UI is made possible by many contributors and open-source libraries. Tha
## Dev environment
Follow the [dev environment](/development/setup/dev-environment/) guide to get set up. Run the UI using `pnpm dev`.
Follow the [dev environment](/development/setup/dev-environment/) guide to get set up. The default UI lives in `invokeai/frontend/webv2`. Run `make frontend-install`, then `make frontend-dev`; `make frontend-build` builds the bundle served by `invokeai-web`. The existing `frontendv2-*` targets remain aliases.
`invokeai/frontend/web` is the legacy frontend. Build it with `make frontend-legacy-build` and select it with `invokeai-web --web-legacy`. `--webv2` remains a compatibility alias for the default UI. Close other editor tabs before switching frontends on the same origin. Existing browser storage and project recovery data retain their names and formats; switching frontends does not migrate or erase them.
## Package scripts
- `dev`: run the frontend in dev mode, enabling hot reloading
- `build`: run all checks (dpdm, eslint, prettier, tsc, knip) and then build the frontend
- `lint:dpdm`: check circular dependencies
- `lint:eslint`: check code quality
- `lint:prettier`: check code formatting
- `lint:tsc`: check type issues
- `lint:knip`: check for unused exports or objects
- `lint`: run all checks concurrently
- `fix`: run `eslint` and `prettier`, fixing fixable issues
- `test:ui`: run `vitest` with the fancy web UI
Run these in `invokeai/frontend/webv2`:
- `dev`: run the frontend with hot reloading
- `build`: run formatting, lint, types, and architecture checks, then build
- `lint`: run formatting, Oxc lint, TypeScript, and architecture checks
- `fix`: fix supported lint and formatting issues
- `test`: run the unit suite
- `test:browser`: run Chromium interaction tests
- `check:release`: run the complete release gates, including performance, project-file, and accessibility journeys
The legacy package has its own scripts and lockfile. See each package's `AGENTS.md` for its commands and ownership rules.
## Type generation
We use [openapi-typescript] to generate types from the app's OpenAPI schema. The generated types are committed to the repo in [schema.ts].
The legacy `web` package owns generation, including shared backend contracts consumed by webv2. We use [openapi-typescript] to generate types from the app's OpenAPI schema. The generated types are committed to the repo in [schema.ts].
If you make backend changes, it's important to regenerate the frontend types:
```sh
set -o pipefail
cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen
```
@@ -53,7 +57,7 @@ Only the English source strings (i.e. `en.json`) should be changed on this repo.
"request": "launch",
"name": "Invoke UI",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/invokeai/frontend/web"
"webRoot": "${workspaceFolder}/invokeai/frontend/webv2"
}
]
}
@@ -85,11 +89,7 @@ Ping `@psychedelicious` on [discord] in the `#frontend-dev` channel or in the fe
## Code conventions
- This is a fairly complex app with a deep component tree. Please use memoization (`useCallback`, `useMemo`, `memo`) with enthusiasm.
- If you need to add some global, ephemeral state, please use [nanostores] if possible.
- Be careful with your redux selectors. If they need to be parameterized, consider creating them inside a `useMemo`.
- Feel free to use `lodash` (via `lodash-es`) to make the intent of your code clear.
- Please add comments describing the "why", not the "how" (unless it is really arcane).
Follow `invokeai/frontend/webv2/AGENTS.md` and its `ARCHITECTURE.md` for ownership, state, React, persistence, and product-quality rules. The linked Redux and control-layer guides describe the legacy frontend.
## Commit format
@@ -102,11 +102,7 @@ Please use the [conventional commits] spec for the web UI, with a scope of "ui":
## Tests
We don't do any UI testing at this time, but consider adding tests for sensitive logic.
We use `vitest`, and tests should be next to the file they are testing. If the logic is in `something.ts`, the tests should be in `something.test.ts`.
In some situations, we may want to test types. For example, if you use `zod` to create a schema that should match a generated type, it's best to add a test to confirm that the types match. Use `tsafe`'s assert for this.
Colocate unit tests and Chromium browser tests with the owning code. Use real browser storage and interaction where mocks cannot establish correctness. Run `pnpm check:release` before milestone readiness; browser screenshots and interaction review complement automated gates.
## Submitting a PR
@@ -489,7 +489,7 @@ clients automatically:
## Frontend behavior
### webv2 (`--webv2`)
### webv2 (default UI)
The update lands in the **Generate** panel of the project that was active when
the event arrived, and shows a notification summarizing how many fields were
@@ -7,35 +7,28 @@ import { Tabs, TabItem } from '@astrojs/starlight/components';
## Frontend Tests
We use `vitest` to run the frontend tests. (See [vite.config.ts](https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/vite.config.mts) for the default `vitest` options.)
Below are some common frontend test commands:
The default frontend is `webv2`, with unit and Chromium suites. Run commands from the repository root:
<Tabs>
<TabItem label="Make">
```bash
# From the repository root, run the frontend test suite once.
# Run the default frontend unit suite once.
make frontend-test
```
</TabItem>
<TabItem label="pnpm">
```bash
# From the repository root, run the full frontend test suite in watch mode.
pnpm --dir invokeai/frontend/web test
# Run the frontend test suite once without watch mode.
pnpm --dir invokeai/frontend/web test:no-watch
# Equivalent one-shot command using the underlying Vitest subcommand.
pnpm --dir invokeai/frontend/web test:run
# Open the Vitest UI and collect coverage data while running tests.
pnpm --dir invokeai/frontend/web test:ui
pnpm -C invokeai/frontend/webv2 test
pnpm -C invokeai/frontend/webv2 test:browser
# Complete milestone gates, including performance, project files, and accessibility.
pnpm -C invokeai/frontend/webv2 check:release
```
</TabItem>
</Tabs>
If you are already working from `invokeai/frontend/web`, you can omit the `--dir` flag and run the same `pnpm` scripts directly from that directory.
Install Chromium with `pnpm -C invokeai/frontend/webv2 exec playwright install chromium` if needed. Read `webv2/AGENTS.md` for test ownership and browser verification rules.
The legacy frontend remains in `invokeai/frontend/web`. Run its separate suite with `pnpm -C invokeai/frontend/web test:no-watch` and its checks with `pnpm -C invokeai/frontend/web lint`.
## Backend Tests
@@ -75,14 +75,14 @@ import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro'
7. Install the frontend dev toolchain, paying attention to versions:
- [`nodejs`](https://nodejs.org/) (tested on LTS, v22)
- [`nodejs`](https://nodejs.org/) (use the version in `.nvmrc`)
- [`pnpm`](https://pnpm.io/installation) (tested on v10)
8. Do a production build of the frontend:
```sh
cd <PATH_TO_INVOKEAI_REPO>/invokeai/frontend/web
pnpm i
cd <PATH_TO_INVOKEAI_REPO>/invokeai/frontend/webv2
pnpm install --frozen-lockfile
pnpm build
```
@@ -105,12 +105,14 @@ See the [Tests](../tests) documentation for information about running and writin
## Frontend Development
The default frontend is `webv2`. To use legacy `web`, build it with `make frontend-legacy-build` and launch `invokeai-web --web-legacy`.
You'll need to run `pnpm build` every time you pull in new changes to the frontend.
Another option is to skip the build and instead run the UI in dev mode:
```sh
cd invokeai/frontend/web
cd invokeai/frontend/webv2
pnpm dev
```
+12
View File
@@ -0,0 +1,12 @@
from pathlib import Path
from starlette.applications import Starlette
from invokeai.app.api.no_cache_staticfiles import NoCacheStaticFiles
def mount_frontend(app: Starlette, frontend_root: Path, *, legacy: bool = False) -> None:
"""Mount shared documentation assets before the selected UI's catch-all SPA route."""
ui_root = frontend_root.parent / "web" if legacy else frontend_root
app.mount("/static", NoCacheStaticFiles(directory=frontend_root.parent / "web" / "static"), name="static")
app.mount("/", NoCacheStaticFiles(directory=ui_root / "dist", html=True), name="ui")
+6 -12
View File
@@ -19,9 +19,9 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoin
from starlette.middleware.gzip import GZipMiddleware, GZipResponder, IdentityResponder
from starlette.types import ASGIApp, Message, Receive, Scope, Send
import invokeai.frontend.web as web_dir
import invokeai.frontend.webv2 as webv2_dir
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.no_cache_staticfiles import NoCacheStaticFiles
from invokeai.app.api.frontend import mount_frontend
from invokeai.app.api.routers import (
app_info,
auth,
@@ -788,10 +788,7 @@ def overridden_redoc(request: Request) -> HTMLResponse:
)
web_root_path = Path(list(web_dir.__path__)[0])
ui_root_path = web_root_path
if InvokeAIArgs.did_parse and getattr(InvokeAIArgs.args, "webv2", False):
ui_root_path = web_root_path.parent / "webv2"
web_root_path = Path(list(webv2_dir.__path__)[0])
if app_config.unsafe_disable_picklescan:
logger.warning(
@@ -800,9 +797,6 @@ if app_config.unsafe_disable_picklescan:
)
try:
app.mount("/", NoCacheStaticFiles(directory=Path(ui_root_path, "dist"), html=True), name="ui")
except RuntimeError:
logger.warning(f"No UI found at {ui_root_path}/dist, skipping UI mount")
app.mount(
"/static", NoCacheStaticFiles(directory=Path(web_root_path, "static/")), name="static"
) # docs favicon is in here
mount_frontend(app, web_root_path, legacy=getattr(InvokeAIArgs.args, "web_legacy", False))
except RuntimeError as error:
logger.warning(f"No UI found, skipping UI mount: {error}")
+5 -2
View File
@@ -13,8 +13,11 @@ _config_file_help = r"""Path to the invokeai.yaml configuration file. If omitted
_parser = ArgumentParser(description="Invoke Studio", formatter_class=RawTextHelpFormatter)
_parser.add_argument("--root", type=str, help=_root_help)
_parser.add_argument("--config", dest="config_file", type=str, help=_config_file_help)
_webv2_help = r"""Serve the webv2 frontend (invokeai/frontend/webv2) instead of the legacy web frontend. The webv2 frontend must be built first (make frontendv2-build)."""
_parser.add_argument("--webv2", action="store_true", help=_webv2_help)
_frontend_group = _parser.add_mutually_exclusive_group()
_frontend_group.add_argument("--webv2", action="store_true", help="Serve the default frontend (compatibility alias).")
_frontend_group.add_argument(
"--web-legacy", action="store_true", help="Serve the legacy frontend instead of the default UI."
)
_parser.add_argument("--version", action="version", version=__version__, help="Displays the version and exits.")
+2
View File
@@ -1,3 +1,5 @@
> This is the legacy frontend. InvokeAI serves `../webv2/` by default; use `--web-legacy` to serve this bundle. Build it with `make frontend-legacy-build`. Generated OpenAPI and TypeScript API artifacts remain owned here.
# Invoke UI
<https://invoke.ai/development/front-end/>
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { retireDefaultServiceWorker } from './retireDefaultServiceWorker';
afterEach(() => vi.unstubAllGlobals());
describe('legacy frontend service-worker handoff', () => {
const base = 'https://example.com/invoke';
const setup = (scriptURL: string, controlled: boolean) => {
const unregister = vi.fn(() => Promise.resolve(true));
const getRegistration = vi.fn(() => Promise.resolve({ active: { scriptURL }, unregister }));
const reload = vi.fn();
const entries = new Map([
['invokeai-shell-old', new Set([`${base}/index.html`, 'https://example.com/other/index.html'])],
['invokeai-assets', new Set([`${base}/assets/main.js`, 'https://example.com/other/assets/main.js'])],
['invokeai-runtime', new Set([`${base}/locales/en.json`, 'https://example.com/other/locales/en.json'])],
['another-app', new Set([`${base}/index.html`])],
]);
const cacheStorage = {
keys: () => Promise.resolve([...entries.keys()]),
open: (name: string) =>
Promise.resolve({
keys: () => Promise.resolve([...entries.get(name)!].map((url) => new Request(url))),
delete: (request: Request) => Promise.resolve(entries.get(name)!.delete(request.url)),
}),
};
vi.stubGlobal('navigator', { serviceWorker: { getRegistration, controller: controlled ? { scriptURL } : null } });
vi.stubGlobal('window', { caches: cacheStorage, location: { reload } });
vi.stubGlobal('caches', cacheStorage);
return { unregister, getRegistration, reload, entries };
};
it('releases the deployment worker and only disposable Invoke caches before reloading', async () => {
const { unregister, getRegistration, reload, entries } = setup(`${base}/sw.js`, true);
expect(await retireDefaultServiceWorker(base)).toBe(true);
expect(getRegistration).toHaveBeenCalledWith(`${base}/`);
expect(unregister).toHaveBeenCalledOnce();
expect(entries).toEqual(
new Map([
['invokeai-shell-old', new Set(['https://example.com/other/index.html'])],
['invokeai-assets', new Set(['https://example.com/other/assets/main.js'])],
['invokeai-runtime', new Set(['https://example.com/other/locales/en.json'])],
['another-app', new Set([`${base}/index.html`])],
])
);
expect(reload).toHaveBeenCalledOnce();
});
it('continues boot without a reload when the document is already uncontrolled', async () => {
const { reload } = setup(`${base}/sw.js`, false);
expect(await retireDefaultServiceWorker(base)).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
it('leaves a different application worker and its caches alone', async () => {
const { unregister, reload, entries } = setup('https://example.com/sw.js', true);
expect(await retireDefaultServiceWorker(base)).toBe(false);
expect(unregister).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
expect(entries.get('invokeai-assets')).toEqual(
new Set([`${base}/assets/main.js`, 'https://example.com/other/assets/main.js'])
);
});
it('boots normally when service workers are unavailable', async () => {
vi.stubGlobal('navigator', {});
expect(await retireDefaultServiceWorker(base)).toBe(false);
});
});
@@ -0,0 +1,41 @@
/** Release the default UI's worker before legacy loads its differently shaped locale files. */
export const retireDefaultServiceWorker = async (deploymentBaseUrl: string): Promise<boolean> => {
if (!('serviceWorker' in navigator)) {
return false;
}
const scriptUrl = `${deploymentBaseUrl}/sw.js`;
const registration = await navigator.serviceWorker.getRegistration(`${deploymentBaseUrl}/`);
if (registration?.active?.scriptURL !== scriptUrl) {
return false;
}
await registration.unregister();
// Asset caches are disposable. Never clear localStorage or IndexedDB recovery drafts.
if ('caches' in window) {
const names = await caches.keys();
const indexUrl = `${deploymentBaseUrl}/index.html`;
const assetPrefix = `${deploymentBaseUrl}/assets/`;
const localePrefix = `${deploymentBaseUrl}/locales/`;
await Promise.all(
names
.filter(
(name) => name.startsWith('invokeai-shell-') || name === 'invokeai-assets' || name === 'invokeai-runtime'
)
.map(async (name) => {
const cache = await caches.open(name);
const requests = await cache.keys();
// Cache names are origin-wide, so another deployment may share each bucket.
await Promise.all(
requests
.filter(({ url }) => url === indexUrl || url.startsWith(assetPrefix) || url.startsWith(localePrefix))
.map((request) => cache.delete(request))
);
})
);
}
// Unregistration leaves the current document controlled until its next navigation.
if (navigator.serviceWorker.controller?.scriptURL === scriptUrl) {
window.location.reload();
return true;
}
return false;
};
+20 -2
View File
@@ -1,5 +1,23 @@
import ReactDOM from 'react-dom/client';
import InvokeAIUI from './app/components/InvokeAIUI';
import { getDeploymentBaseUrl } from './common/util/baseUrl';
import { retireDefaultServiceWorker } from './common/util/retireDefaultServiceWorker';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<InvokeAIUI />);
const boot = async () => {
if (await retireDefaultServiceWorker(getDeploymentBaseUrl())) {
return;
}
const { default: InvokeAIUI } = await import('./app/components/InvokeAIUI');
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<InvokeAIUI />);
};
void boot().catch(() => {
const root = document.getElementById('root');
if (root) {
root.textContent = 'Unable to start the legacy frontend. Reload to retry.';
const retry = document.createElement('button');
retry.textContent = 'Reload';
retry.addEventListener('click', () => window.location.reload());
root.appendChild(retry);
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
# Webv2
Read [ARCHITECTURE.md](ARCHITECTURE.md) for ownership/interface changes and affected owner-local READMEs for lifecycle/persistence. Stack: React 19, Vite, Chakra UI 3, TanStack Query/Router, @dnd-kit; legacy web differs.
Read [ARCHITECTURE.md](ARCHITECTURE.md) for ownership/interface changes and affected owner-local READMEs for lifecycle/persistence. Stack: React 19, Vite, Chakra UI 3, TanStack Query/Router, @dnd-kit; legacy web differs. Webv2 is served by default; `--webv2` is a compatibility alias and `--web-legacy` selects `../web/`.
## Ownership and state
+1
View File
@@ -0,0 +1 @@
"""Default InvokeAI frontend package."""
+3 -3
View File
@@ -7,7 +7,7 @@
"#architecture/source-analysis": "./src/architecture/tsSourceAnalysis.ts"
},
"scripts": {
"architecture:check": "vitest run --config vitest.config.mts src/architecture/dependencyPolicy.test.ts src/architecture/workbenchOwnership.test.ts src/architecture/architectureWorkflow.test.ts src/architecture/canvasSeamOwnership.test.ts src/architecture/tsSourceAnalysis.test.ts src/architecture/secureContextApis.test.ts src/architecture/axeAudits.test.ts && node scripts/write-workbench-ownership-inventory.mjs && node scripts/write-contract-caller-matrix.mjs && node scripts/write-canvas-import-matrix.mjs",
"architecture:check": "vitest run --config vitest.config.mts src/architecture/dependencyPolicy.test.ts src/architecture/workbenchOwnership.test.ts src/architecture/architectureWorkflow.test.ts src/architecture/canvasSeamOwnership.test.ts src/architecture/tsSourceAnalysis.test.ts src/architecture/secureContextApis.test.ts src/architecture/axeAudits.test.ts && node --experimental-strip-types scripts/write-workbench-ownership-inventory.mjs && node --experimental-strip-types scripts/write-contract-caller-matrix.mjs && node --experimental-strip-types scripts/write-canvas-import-matrix.mjs",
"build": "pnpm run lint && vite build",
"check:architecture": "pnpm run lint && pnpm run test:all && pnpm run test:performance:architecture && pnpm run test:project-files:browser",
"check:release": "pnpm run check:architecture && pnpm run test:accessibility:browser",
@@ -34,8 +34,8 @@
"test:performance:architecture": "pnpm run test:performance:build && pnpm run test:performance:browser",
"test:performance:browser": "node scripts/measure-architecture-performance.mjs",
"test:performance:browser:update-baseline": "node scripts/measure-architecture-performance.mjs --update-baseline",
"test:performance:build": "pnpm run test:performance:contracts && vite build && node scripts/check-architecture-performance.mjs",
"test:performance:build:update-baseline": "pnpm run test:performance:contracts && vite build && node scripts/check-architecture-performance.mjs --update-baseline",
"test:performance:build": "pnpm run test:performance:contracts && vite build && node --experimental-strip-types scripts/check-architecture-performance.mjs",
"test:performance:build:update-baseline": "pnpm run test:performance:contracts && vite build && node --experimental-strip-types scripts/check-architecture-performance.mjs --update-baseline",
"test:performance:contracts": "node --test scripts/performance-budgets.test.mjs",
"test:project-files": "vite build && pnpm run test:project-files:browser",
"test:project-files:browser": "node scripts/run-project-file-journey.mjs"
@@ -41,4 +41,63 @@ describe('architecture workflow', () => {
expect(completion.run).toBe('pnpm check:release');
expect(completion.env.WEBV2_PERF_REFERENCE_DIR).toContain('steps.perf-ref.outputs.cache-matched-key');
});
it('installs dependencies in the package that owns each frontend and generation gate', () => {
const sources = import.meta.glob(
[
'../../../../../.github/workflows/{frontend-checks,frontend-tests,openapi-checks,typegen-checks}.yml',
'../../../../../.github/actions/install-frontend-deps/action.yml',
],
{ eager: true, import: 'default', query: '?raw' }
) as Record<string, string>;
const action = parse(Object.entries(sources).find(([path]) => path.endsWith('/action.yml'))![1]);
expect(action.inputs['working-directory'].default).toBe('invokeai/frontend/webv2');
for (const [path, source] of Object.entries(sources).filter(([path]) => path.includes('/workflows/'))) {
const jobs = parse(source).jobs;
for (const [id, job] of Object.entries(jobs) as [
string,
{
defaults?: { run?: { 'working-directory'?: string } };
steps: { uses?: string; with?: { 'working-directory'?: string } }[];
},
][]) {
const install = job.steps.find((step) => step.uses === './.github/actions/install-frontend-deps');
if (!install) {
continue;
}
const expected = id.startsWith('frontend-webv2-') ? 'invokeai/frontend/webv2' : 'invokeai/frontend/web';
expect(install.with?.['working-directory'] ?? action.inputs['working-directory'].default, path).toBe(expected);
if (job.defaults?.run) {
expect(job.defaults.run['working-directory'], path).toBe(expected);
}
}
}
});
it('runs every frontend-dependent gate when the shared Node version changes', () => {
const sources = import.meta.glob(
[
'../../../../../.github/workflows/{frontend-checks,frontend-tests,openapi-checks,typegen-checks}.yml',
'../../../../../.github/actions/install-frontend-deps/action.yml',
],
{ eager: true, import: 'default', query: '?raw' }
) as Record<string, string>;
const action = parse(Object.entries(sources).find(([path]) => path.endsWith('/action.yml'))![1]);
const nodeVersionFile = action.runs.steps.find((step: { uses?: string }) =>
step.uses?.startsWith('actions/setup-node@')
).with['node-version-file'];
const workflows = Object.entries(sources).filter(([path]) => path.includes('/workflows/'));
expect(workflows).toHaveLength(4);
for (const [path, source] of workflows) {
const jobs = parse(source).jobs as Record<string, { steps: { uses?: string; with?: { files_yaml?: string } }[] }>;
for (const job of Object.values(jobs)) {
if (!job.steps.some((step) => step.uses === './.github/actions/install-frontend-deps')) {
continue;
}
const filter = job.steps.find((step) => step.with?.files_yaml)?.with?.files_yaml ?? '';
const patterns = Object.values(parse(filter) as Record<string, string[]>).flat();
expect(patterns, path).toContain(nodeVersionFile);
}
}
});
});
+2
View File
@@ -328,6 +328,7 @@ version = { attr = "invokeai.version.__version__" }
"invokeai.backend" = ["**.png", "**/*.icc"]
"invokeai.configs" = ["*.example", "**/*.yaml", "*.txt"]
"invokeai.frontend.web.dist" = ["**"]
"invokeai.frontend.webv2.dist" = ["**"]
"invokeai.frontend.web.static" = ["**"]
"invokeai.app.invocations" = ["**"]
@@ -370,6 +371,7 @@ exclude = [
"build",
"dist",
"invokeai/frontend/web/node_modules/",
"invokeai/frontend/webv2/node_modules/",
".venv*",
"*.ipynb",
"invokeai/backend/image_util/mediapipe_face/", # External code
+13 -17
View File
@@ -46,22 +46,18 @@ echo
# ---------------------- FRONTEND ----------------------
pushd ../invokeai/frontend/web >/dev/null
echo "Installing frontend dependencies..."
echo
pnpm i --frozen-lockfile
echo
if [[ ! -z ${CI} ]]; then
echo "Building frontend without checks..."
# In CI, we have already done the frontend checks and can just build
pnpm vite build
else
echo "Running checks and building frontend..."
# This runs all the frontend checks and builds
pnpm build
fi
echo
popd
for frontend in webv2 web; do
pushd "../invokeai/frontend/$frontend" >/dev/null
echo "Installing $frontend dependencies..."
pnpm install --frozen-lockfile
if [[ ! -z ${CI} ]]; then
# CI runs each package's completion gates before building distributions.
pnpm exec vite build
else
pnpm build
fi
popd
done
# ---------------------- BACKEND ----------------------
@@ -85,7 +81,7 @@ echo -e "${BGREEN}Built PyPi distribution: ./dist${RESET}"
if [[ -z ${CI} ]]; then
echo
echo "Cleaning up intermediate build files..."
rm -rf InvokeAI-Installer tmp ../invokeai/frontend/web/dist/
rm -rf InvokeAI-Installer tmp ../invokeai/frontend/webv2/dist/ ../invokeai/frontend/web/dist/
fi
if [[ ! -z ${CI} ]]; then
+75
View File
@@ -0,0 +1,75 @@
import sys
from pathlib import Path
import pytest
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
from invokeai.app.api.frontend import mount_frontend
from invokeai.frontend.cli.arg_parser import InvokeAIArgs
@pytest.fixture
def frontend_root(tmp_path: Path) -> Path:
for name, marker in [("webv2", "default frontend"), ("web", "legacy frontend")]:
bundle = tmp_path / name / "dist"
(bundle / "assets").mkdir(parents=True)
(bundle / "index.html").write_text(marker)
(bundle / "assets" / "main.js").write_text(f"console.log('{marker}')")
root = tmp_path / "web"
(root / "static" / "docs").mkdir(parents=True)
(root / "static" / "docs" / "invoke-favicon-docs.svg").write_text("<svg>docs favicon</svg>")
return tmp_path / "webv2"
@pytest.mark.parametrize("prefix", ["", "/invoke"])
@pytest.mark.parametrize(
("flags", "marker"),
[([], "default frontend"), (["--webv2"], "default frontend"), (["--web-legacy"], "legacy frontend")],
)
def test_launch_serves_selected_bundle_and_preserves_api_and_docs(
frontend_root: Path, monkeypatch: pytest.MonkeyPatch, flags: list[str], marker: str, prefix: str
) -> None:
monkeypatch.setattr(sys, "argv", ["invokeai-web", *flags])
monkeypatch.setattr(InvokeAIArgs, "args", None)
monkeypatch.setattr(InvokeAIArgs, "did_parse", False)
args = InvokeAIArgs.parse_args()
app = Starlette(routes=[Route("/api/ping", lambda request: PlainTextResponse("backend"))])
mount_frontend(app, frontend_root, legacy=getattr(args, "web_legacy", False))
host = Starlette(routes=[Mount(prefix, app=app)]) if prefix else app
with TestClient(host) as client:
for path in ["/", "/projects/example"]:
response = client.get(f"{prefix}{path}")
assert response.status_code == 200
assert response.text == marker
assert "no-store" in response.headers["cache-control"]
assert client.get(f"{prefix}/assets/main.js").text == f"console.log('{marker}')"
favicon = client.get(f"{prefix}/static/docs/invoke-favicon-docs.svg")
assert favicon.status_code == 200
assert favicon.text == "<svg>docs favicon</svg>"
assert client.get(f"{prefix}/static/missing.svg").status_code == 404
assert client.get(f"{prefix}/api/ping").text == "backend"
def test_conflicting_frontend_flags_are_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["invokeai-web", "--webv2", "--web-legacy"])
with pytest.raises(SystemExit, match="2"):
InvokeAIArgs.parse_args()
def test_missing_legacy_bundle_does_not_silently_serve_default(frontend_root: Path) -> None:
(frontend_root.parent / "web" / "dist").rename(frontend_root.parent / "old-dist")
with pytest.raises(RuntimeError, match="web"):
mount_frontend(Starlette(), frontend_root, legacy=True)
def test_missing_default_bundle_preserves_docs_without_falling_back_to_legacy(frontend_root: Path) -> None:
(frontend_root / "dist").rename(frontend_root.parent / "old-dist")
app = Starlette()
with pytest.raises(RuntimeError, match="webv2"):
mount_frontend(app, frontend_root)
with TestClient(app) as client:
assert client.get("/").status_code == 404
assert client.get("/static/docs/invoke-favicon-docs.svg").text == "<svg>docs favicon</svg>"