@cavulsqa/create 2.8.2 → 2.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/scaffold.mjs +8 -21
- package/lib/templateFiles.mjs +62 -0
- package/package.json +2 -2
- package/templates/f7-app/CLAUDE.md +3 -0
- package/templates/f7-app/auto-imports.d.ts +6 -0
- package/templates/f7-app/package.json +3 -3
- package/templates/f7-app/src/App.vue +1 -0
- package/templates/f7-app/src/app/storage.config.ts +10 -1
- package/templates/f7-app/src/locales/en.json +0 -1
- package/templates/f7-app/src/locales/fr.json +0 -1
- package/templates/f7-app/src/modules/demo/router/routes/demo.routes.ts +4 -16
- package/templates/f7-app/src/modules/home/router/routes/home.routes.ts +3 -11
- package/templates/f7-app/src/modules/settings/router/routes/settings.routes.ts +2 -6
- package/templates/f7-app/src/modules/settings/views/SettingsView.vue +1 -1
- package/templates/f7-app/src/router/global/global.routes.ts +2 -6
- package/templates/f7-app/src/shared/composables/useNavigationGuard.ts +48 -0
- package/templates/f7-app/src/shared/database/database.ts +9 -1
- package/templates/f7-app/src/shared/utils/lazyRoute.ts +25 -0
- package/templates/f7-app/tests/lazyRoute.test.ts +30 -0
- package/templates/f7-app/tests/navigationGuard.test.ts +82 -0
- package/templates/f7-app/vite.config.ts +9 -1
package/lib/scaffold.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { listTemplateFiles } from "./templateFiles.mjs";
|
|
1
4
|
import { pruneEngines } from "./pruneEngines.mjs";
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { basename, join } from "node:path";
|
|
4
5
|
|
|
5
6
|
/** Files whose contents carry the app's identity and have to be rewritten, not copied. */
|
|
6
7
|
function personalise(entry, text, { name, appName }) {
|
|
@@ -38,26 +39,12 @@ function manifest(source, { name, appName, templateName }) {
|
|
|
38
39
|
)}\n`;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
* Installed, generated or platform-specific: none of it belongs in a generated app.
|
|
43
|
-
*
|
|
44
|
-
* A bundled template never contains these, but `--from` points at a live working copy that usually
|
|
45
|
-
* does - and `node_modules` is full of directory symlinks, which `isDirectory()` reports as files and
|
|
46
|
-
* `readFileSync` then rejects with EISDIR. `--from` could not generate anything at all.
|
|
47
|
-
*/
|
|
48
|
-
const SKIP = new Set(["node_modules", "dist", "android", "ios", ".git", "pnpm-lock.yaml"]);
|
|
49
|
-
|
|
50
42
|
function copyTree(from, to, transform) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
if (entry.isDirectory()) {
|
|
57
|
-
copyTree(source, target, transform);
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
const rewritten = transform(entry.name, source);
|
|
43
|
+
for (const file of listTemplateFiles(from)) {
|
|
44
|
+
const source = join(from, file);
|
|
45
|
+
const target = join(to, file);
|
|
46
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
47
|
+
const rewritten = transform(basename(file), source);
|
|
61
48
|
if (rewritten === null) cpSync(source, target);
|
|
62
49
|
else writeFileSync(target, rewritten);
|
|
63
50
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generated, installed or platform-specific: none of it belongs in a template.
|
|
7
|
+
*
|
|
8
|
+
* Only consulted when git cannot answer - a bundled template carries no repository.
|
|
9
|
+
*/
|
|
10
|
+
const SKIP = new Set(["node_modules", "dist", "android", "ios", ".git", "pnpm-lock.yaml"]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The files a template consists of, as paths relative to `dir`.
|
|
14
|
+
*
|
|
15
|
+
* Asks git first, and falls back to walking only when git cannot answer. A name blocklist is the
|
|
16
|
+
* wrong instrument for a working copy: it lets through anything nobody thought to name, and a
|
|
17
|
+
* maintainer's stray `build/` and a deploy log both reached a generated app that way. Git already
|
|
18
|
+
* knows what belongs - it is the same list the template fingerprint is computed from, so what ships
|
|
19
|
+
* and what is hashed cannot drift apart.
|
|
20
|
+
*/
|
|
21
|
+
export function listTemplateFiles(dir) {
|
|
22
|
+
const tracked = trackedFiles(dir);
|
|
23
|
+
return tracked ?? walk(dir, dir);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function trackedFiles(dir) {
|
|
27
|
+
if (!existsSync(join(dir, ".git")) && !insideRepository(dir)) return null;
|
|
28
|
+
try {
|
|
29
|
+
const listing = execFileSync("git", ["ls-files", "-z"], {
|
|
30
|
+
cwd: dir,
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
const files = listing
|
|
35
|
+
.split("\0")
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.map((file) => file.split("/").join(sep));
|
|
38
|
+
return files.length ? files.filter((file) => !SKIP.has(file.split(sep)[0])) : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function insideRepository(dir) {
|
|
45
|
+
try {
|
|
46
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir, stdio: "ignore" });
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function walk(root, dir) {
|
|
54
|
+
const found = [];
|
|
55
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
if (SKIP.has(entry.name)) continue;
|
|
57
|
+
const full = join(dir, entry.name);
|
|
58
|
+
if (entry.isDirectory()) found.push(...walk(root, full));
|
|
59
|
+
else found.push(relative(root, full));
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cavulsqa/create",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.3",
|
|
4
4
|
"description": "Create a Vue + Framework7 + Capacitor + SQLite app from the cavulsqa templates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"capacitor",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
}
|
|
53
53
|
]
|
|
54
54
|
},
|
|
55
|
-
"templatesFingerprint": "sha256-
|
|
55
|
+
"templatesFingerprint": "sha256-6R7B6B3lY7wTuktecqa5vTipEb1bILSBEAhOvLXmiTM=",
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "node scripts/bundleTemplates.mjs",
|
|
58
58
|
"check": "vp check",
|
|
@@ -71,6 +71,9 @@ The short version of the rules, each earned by a real bug:
|
|
|
71
71
|
kysely reports `no result`. See `.claude/rules/database.md`.
|
|
72
72
|
- **Money is integer cents.** A float total is a rounding bug waiting to happen.
|
|
73
73
|
- **`f7route` and `f7router` are props**, not imports: `defineProps<{ f7route: Router.Route }>()`.
|
|
74
|
+
- **A route loads its component through `lazyRoute`**, never a bare `import().then(resolve)`.
|
|
75
|
+
Framework7 locks the router before calling the hook and only unlocks it from `resolve` or
|
|
76
|
+
`reject`; a hook that settles neither — one failed chunk — freezes that view for the session.
|
|
74
77
|
- **In locale files, `@` and `|` are message syntax.** `@` starts a linked message and `|` a plural
|
|
75
78
|
branch. A literal `@` must be `{'@'}` or the whole locale file fails to compile.
|
|
76
79
|
- **No comments that narrate.** A comment explains a non-obvious _why_ — a platform quirk, an
|
|
@@ -58,12 +58,14 @@ declare global {
|
|
|
58
58
|
const initCapacitor: typeof import('./src/plugins/capacitor/index').initCapacitor
|
|
59
59
|
const inject: typeof import('vue').inject
|
|
60
60
|
const injectLocal: typeof import('@vueuse/core').injectLocal
|
|
61
|
+
const installNavigationGuard: typeof import('./src/shared/composables/useNavigationGuard').installNavigationGuard
|
|
61
62
|
const isDefined: typeof import('@vueuse/core').isDefined
|
|
62
63
|
const isProxy: typeof import('vue').isProxy
|
|
63
64
|
const isReactive: typeof import('vue').isReactive
|
|
64
65
|
const isReadonly: typeof import('vue').isReadonly
|
|
65
66
|
const isRef: typeof import('vue').isRef
|
|
66
67
|
const isShallow: typeof import('vue').isShallow
|
|
68
|
+
const lazyRoute: typeof import('./src/shared/utils/lazyRoute').lazyRoute
|
|
67
69
|
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
|
|
68
70
|
const markRaw: typeof import('vue').markRaw
|
|
69
71
|
const nextTick: typeof import('vue').nextTick
|
|
@@ -230,6 +232,7 @@ declare global {
|
|
|
230
232
|
const useMouseInElement: typeof import('@vueuse/core').useMouseInElement
|
|
231
233
|
const useMousePressed: typeof import('@vueuse/core').useMousePressed
|
|
232
234
|
const useMutationObserver: typeof import('@vueuse/core').useMutationObserver
|
|
235
|
+
const useNavigationGuard: typeof import('./src/shared/composables/useNavigationGuard').useNavigationGuard
|
|
233
236
|
const useNavigatorLanguage: typeof import('@vueuse/core').useNavigatorLanguage
|
|
234
237
|
const useNetwork: typeof import('@vueuse/core').useNetwork
|
|
235
238
|
const useNow: typeof import('@vueuse/core').useNow
|
|
@@ -403,12 +406,14 @@ declare module 'vue' {
|
|
|
403
406
|
readonly initCapacitor: UnwrapRef<typeof import('./src/plugins/capacitor/index')['initCapacitor']>
|
|
404
407
|
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
|
405
408
|
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
|
|
409
|
+
readonly installNavigationGuard: UnwrapRef<typeof import('./src/shared/composables/useNavigationGuard')['installNavigationGuard']>
|
|
406
410
|
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
|
|
407
411
|
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
|
408
412
|
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
|
409
413
|
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
|
410
414
|
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
|
411
415
|
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
|
|
416
|
+
readonly lazyRoute: UnwrapRef<typeof import('./src/shared/utils/lazyRoute')['lazyRoute']>
|
|
412
417
|
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
|
413
418
|
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
|
414
419
|
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
|
@@ -575,6 +580,7 @@ declare module 'vue' {
|
|
|
575
580
|
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
|
576
581
|
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
|
|
577
582
|
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
|
|
583
|
+
readonly useNavigationGuard: UnwrapRef<typeof import('./src/shared/composables/useNavigationGuard')['useNavigationGuard']>
|
|
578
584
|
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
|
|
579
585
|
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
|
|
580
586
|
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"@capacitor/preferences": "^8.0.0",
|
|
21
21
|
"@capacitor/splash-screen": "^8.0.0",
|
|
22
22
|
"@capacitor/status-bar": "^8.0.0",
|
|
23
|
-
"@cavulsqa/mobile-db": "^1.2.
|
|
24
|
-
"@cavulsqa/reactive-db": "^1.2.
|
|
25
|
-
"@cavulsqa/reactive-vue": "^1.2.
|
|
23
|
+
"@cavulsqa/mobile-db": "^1.2.1",
|
|
24
|
+
"@cavulsqa/reactive-db": "^1.2.1",
|
|
25
|
+
"@cavulsqa/reactive-vue": "^1.2.1",
|
|
26
26
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
27
27
|
"@tailwindcss/vite": "^4.3.3",
|
|
28
28
|
"@vueuse/core": "^14.4.0",
|
|
@@ -57,6 +57,7 @@ const parameters = framework7Parameters(appTheme.value.theme, appTheme.value.dar
|
|
|
57
57
|
onMounted(() => {
|
|
58
58
|
// The native handlers need the Framework7 instance, which exists only once the shell is mounted.
|
|
59
59
|
f7ready(async (instance) => {
|
|
60
|
+
useNavigationGuard();
|
|
60
61
|
await initCapacitor(instance);
|
|
61
62
|
hideSplashScreen();
|
|
62
63
|
});
|
|
@@ -37,8 +37,17 @@ export const DEFAULT_ORDER: StorageCandidate[] = [
|
|
|
37
37
|
* A typo is reported and ignored rather than fatal: it should not brick the build's output, but a
|
|
38
38
|
* setting that was silently dropped looks exactly like one that was applied and did nothing.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* What `VITE_STORAGE_ENGINE` was at build time, or undefined.
|
|
42
|
+
*
|
|
43
|
+
* Exported so the open can report it. Vite inlines this when it bundles, so an empty value means
|
|
44
|
+
* the variable never reached the build - a `.env` written as UTF-16 by PowerShell's `>` parses as
|
|
45
|
+
* garbage and is dropped without a word.
|
|
46
|
+
*/
|
|
47
|
+
export const preferredEngine = import.meta.env.VITE_STORAGE_ENGINE;
|
|
48
|
+
|
|
40
49
|
function preferredFirst(candidates: StorageCandidate[]): StorageCandidate[] {
|
|
41
|
-
const preferred =
|
|
50
|
+
const preferred = preferredEngine;
|
|
42
51
|
if (!preferred) return candidates;
|
|
43
52
|
|
|
44
53
|
if (!isStorageId(preferred)) {
|
|
@@ -117,7 +117,6 @@
|
|
|
117
117
|
"footer": "Change these screens in src/modules/settings/.",
|
|
118
118
|
"storage": "Storage",
|
|
119
119
|
"engine": "Database engine",
|
|
120
|
-
"engineHint": "Every query reaches SQLite compiled to WebAssembly, against a real file in the Origin Private File System, in a worker - no native bridge. The same engine in a browser as on a phone.",
|
|
121
120
|
"webview": "WebView",
|
|
122
121
|
"notDurable": "Storage is not persisted"
|
|
123
122
|
},
|
|
@@ -117,7 +117,6 @@
|
|
|
117
117
|
"footer": "Modifiez ces écrans dans src/modules/settings/.",
|
|
118
118
|
"storage": "Stockage",
|
|
119
119
|
"engine": "Moteur de base de données",
|
|
120
|
-
"engineHint": "Chaque requête atteint SQLite compilé en WebAssembly, sur un vrai fichier dans l'Origin Private File System, dans un worker - sans pont natif. Le même moteur dans un navigateur que sur un téléphone.",
|
|
121
120
|
"webview": "WebView",
|
|
122
121
|
"notDurable": "Le stockage n'est pas persistant"
|
|
123
122
|
},
|
|
@@ -1,33 +1,21 @@
|
|
|
1
1
|
import type { Router } from "framework7/types";
|
|
2
|
+
import { lazyRoute } from "@/shared/utils/lazyRoute";
|
|
2
3
|
|
|
3
4
|
const demoRoutes: Router.RouteParameters[] = [
|
|
4
5
|
{
|
|
5
6
|
name: "demo",
|
|
6
7
|
path: "/demo/",
|
|
7
|
-
|
|
8
|
-
async({ resolve }) {
|
|
9
|
-
void import("@/modules/demo/views/DemoView.vue").then((view) => {
|
|
10
|
-
resolve({ component: view.default });
|
|
11
|
-
});
|
|
12
|
-
},
|
|
8
|
+
async: lazyRoute(() => import("@/modules/demo/views/DemoView.vue")),
|
|
13
9
|
},
|
|
14
10
|
{
|
|
15
11
|
name: "demo-search",
|
|
16
12
|
path: "/demo/search/",
|
|
17
|
-
async(
|
|
18
|
-
void import("@/modules/demo/views/OrderSearchView.vue").then((view) => {
|
|
19
|
-
resolve({ component: view.default });
|
|
20
|
-
});
|
|
21
|
-
},
|
|
13
|
+
async: lazyRoute(() => import("@/modules/demo/views/OrderSearchView.vue")),
|
|
22
14
|
},
|
|
23
15
|
{
|
|
24
16
|
name: "demo-order",
|
|
25
17
|
path: "/demo/order/:id/",
|
|
26
|
-
async(
|
|
27
|
-
void import("@/modules/demo/views/OrderDetailView.vue").then((view) => {
|
|
28
|
-
resolve({ component: view.default });
|
|
29
|
-
});
|
|
30
|
-
},
|
|
18
|
+
async: lazyRoute(() => import("@/modules/demo/views/OrderDetailView.vue")),
|
|
31
19
|
},
|
|
32
20
|
];
|
|
33
21
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Router } from "framework7/types";
|
|
2
|
+
import { lazyRoute } from "@/shared/utils/lazyRoute";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The detail route is a child of the tab's own view, which is what keeps the tab bar in place and
|
|
@@ -8,21 +9,12 @@ const homeRoutes: Router.RouteParameters[] = [
|
|
|
8
9
|
{
|
|
9
10
|
name: "home",
|
|
10
11
|
path: "/home/",
|
|
11
|
-
|
|
12
|
-
async({ resolve }) {
|
|
13
|
-
void import("@/modules/home/views/HomeView.vue").then((view) => {
|
|
14
|
-
resolve({ component: view.default });
|
|
15
|
-
});
|
|
16
|
-
},
|
|
12
|
+
async: lazyRoute(() => import("@/modules/home/views/HomeView.vue")),
|
|
17
13
|
},
|
|
18
14
|
{
|
|
19
15
|
name: "home-feature",
|
|
20
16
|
path: "/home/feature/:id/",
|
|
21
|
-
async(
|
|
22
|
-
void import("@/modules/home/views/FeatureDetailView.vue").then((view) => {
|
|
23
|
-
resolve({ component: view.default });
|
|
24
|
-
});
|
|
25
|
-
},
|
|
17
|
+
async: lazyRoute(() => import("@/modules/home/views/FeatureDetailView.vue")),
|
|
26
18
|
},
|
|
27
19
|
];
|
|
28
20
|
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import type { Router } from "framework7/types";
|
|
2
|
+
import { lazyRoute } from "@/shared/utils/lazyRoute";
|
|
2
3
|
|
|
3
4
|
const settingsRoutes: Router.RouteParameters[] = [
|
|
4
5
|
{
|
|
5
6
|
name: "settings",
|
|
6
7
|
path: "/settings/",
|
|
7
|
-
|
|
8
|
-
async({ resolve }) {
|
|
9
|
-
void import("@/modules/settings/views/SettingsView.vue").then((view) => {
|
|
10
|
-
resolve({ component: view.default });
|
|
11
|
-
});
|
|
12
|
-
},
|
|
8
|
+
async: lazyRoute(() => import("@/modules/settings/views/SettingsView.vue")),
|
|
13
9
|
},
|
|
14
10
|
];
|
|
15
11
|
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
</F7ListItem>
|
|
53
53
|
</F7List>
|
|
54
54
|
<F7Block class="mt-0!">
|
|
55
|
-
<p class="m-0 text-[13px] opacity-60">{{
|
|
55
|
+
<p class="m-0 text-[13px] opacity-60">{{ tradeoff }}</p>
|
|
56
56
|
</F7Block>
|
|
57
57
|
|
|
58
58
|
<F7BlockTitle>{{ t("settings.tryIt") }}</F7BlockTitle>
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import type { Router } from "framework7/types";
|
|
2
|
+
import { lazyRoute } from "@/shared/utils/lazyRoute";
|
|
2
3
|
|
|
3
4
|
const globalRoutes: Router.RouteParameters[] = [
|
|
4
5
|
{
|
|
5
6
|
name: "not-found",
|
|
6
7
|
path: "(.*)",
|
|
7
|
-
|
|
8
|
-
async({ resolve }) {
|
|
9
|
-
void import("@/shared/components/error/404.vue").then((view) => {
|
|
10
|
-
resolve({ component: view.default });
|
|
11
|
-
});
|
|
12
|
-
},
|
|
8
|
+
async: lazyRoute(() => import("@/shared/components/error/404.vue")),
|
|
13
9
|
},
|
|
14
10
|
];
|
|
15
11
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { f7 } from "framework7-vue";
|
|
2
|
+
|
|
3
|
+
interface GuardableRouter {
|
|
4
|
+
allowPageChange: boolean;
|
|
5
|
+
navigate: (...args: never[]) => unknown;
|
|
6
|
+
back: (...args: never[]) => unknown;
|
|
7
|
+
__navigationGuarded?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Framework7 only enforces `allowPageChange` for synchronous routes. Routes here are async, and
|
|
12
|
+
* `asyncResolve` calls the internal `load()` with `ignorePageChange: true`, so a navigate issued
|
|
13
|
+
* while a transition is still animating runs a forward on top of an in-flight backward. That
|
|
14
|
+
* destroys the previous page element and leaves the router holding two copies of the same page;
|
|
15
|
+
* from there `loadBack` takes its same-url early return and back() can never move again, which
|
|
16
|
+
* reads to the user as a frozen screen.
|
|
17
|
+
*
|
|
18
|
+
* Dropping the losing call is a single boolean read - no queueing, no deferral - so the navigation
|
|
19
|
+
* that does win runs at exactly the same speed as before.
|
|
20
|
+
*/
|
|
21
|
+
export function installNavigationGuard(router: GuardableRouter): void {
|
|
22
|
+
if (router.__navigationGuarded) return;
|
|
23
|
+
router.__navigationGuarded = true;
|
|
24
|
+
|
|
25
|
+
let depth = 0;
|
|
26
|
+
|
|
27
|
+
const guard = (method: (...args: never[]) => unknown) => {
|
|
28
|
+
return (...args: never[]) => {
|
|
29
|
+
if (depth === 0 && !router.allowPageChange) return router;
|
|
30
|
+
depth += 1;
|
|
31
|
+
try {
|
|
32
|
+
return method(...args);
|
|
33
|
+
} finally {
|
|
34
|
+
depth -= 1;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
router.navigate = guard(router.navigate.bind(router));
|
|
40
|
+
router.back = guard(router.back.bind(router));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useNavigationGuard(): void {
|
|
44
|
+
const views = f7.views as unknown as Array<{ router?: GuardableRouter }>;
|
|
45
|
+
for (const view of views ?? []) {
|
|
46
|
+
if (view?.router) installNavigationGuard(view.router);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -3,7 +3,7 @@ import { Migrator } from "kysely/migration";
|
|
|
3
3
|
import { runWrite, type MobileDatabase } from "@cavulsqa/mobile-db";
|
|
4
4
|
import { createChangeBus, createReactiveDb } from "@cavulsqa/reactive-db";
|
|
5
5
|
import { pragmaProfile, pragmasFor } from "@/app/pragmas.config";
|
|
6
|
-
import { storageChain } from "@/app/storage.config";
|
|
6
|
+
import { preferredEngine, storageChain } from "@/app/storage.config";
|
|
7
7
|
import { isStorageId, type StorageAttempt, type StorageCandidate } from "./candidates";
|
|
8
8
|
import { migrations } from "./migrations";
|
|
9
9
|
import { describeOpenFailure } from "./storage";
|
|
@@ -155,6 +155,14 @@ async function tryEveryCandidate(): Promise<MobileDatabase<Database>> {
|
|
|
155
155
|
applied = opened.pragmas;
|
|
156
156
|
chosen = candidate;
|
|
157
157
|
attempts.push({ id: candidate.id, outcome: "opened" });
|
|
158
|
+
// Every launch names its engine. A preference that never arrived - a `.env` the build could
|
|
159
|
+
// not parse, a typo - otherwise looks exactly like one that was applied, and the app reports
|
|
160
|
+
// an engine nobody chose.
|
|
161
|
+
console.info(
|
|
162
|
+
`[storage] opened on ${candidate.id}` +
|
|
163
|
+
(preferredEngine ? ` (VITE_STORAGE_ENGINE=${preferredEngine})` : " (no preference set)"),
|
|
164
|
+
attempts,
|
|
165
|
+
);
|
|
158
166
|
return database;
|
|
159
167
|
} catch (error) {
|
|
160
168
|
attempts.push({ id: candidate.id, outcome: "failed", detail: describeOpenFailure(error) });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Router } from "framework7/types";
|
|
2
|
+
|
|
3
|
+
type RouteComponentModule = { default: unknown };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Framework7 sets `allowPageChange = false` before calling a route's async hook and only restores
|
|
7
|
+
* it from `resolve` or `reject`. A hook that settles neither - a chunk that fails to download, or a
|
|
8
|
+
* module that throws while evaluating - leaves that view's router locked for the rest of the
|
|
9
|
+
* session, so every later navigation and back press is silently ignored. Routing the import
|
|
10
|
+
* through here guarantees the router is always released.
|
|
11
|
+
*/
|
|
12
|
+
export function lazyRoute(
|
|
13
|
+
load: () => Promise<RouteComponentModule>,
|
|
14
|
+
): Router.RouteParameters["async"] {
|
|
15
|
+
return function routeAsync({ resolve, reject }) {
|
|
16
|
+
load()
|
|
17
|
+
.then((module) => {
|
|
18
|
+
resolve({ component: module.default });
|
|
19
|
+
})
|
|
20
|
+
.catch((error: unknown) => {
|
|
21
|
+
console.error("[router] Unable to load route component", error);
|
|
22
|
+
reject();
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { expect, test, vi } from "vite-plus/test";
|
|
2
|
+
import { lazyRoute } from "../src/shared/utils/lazyRoute.js";
|
|
3
|
+
|
|
4
|
+
function callRoute(handler: ReturnType<typeof lazyRoute>) {
|
|
5
|
+
const resolve = vi.fn();
|
|
6
|
+
const reject = vi.fn();
|
|
7
|
+
(handler as (ctx: unknown) => void)({ resolve, reject });
|
|
8
|
+
return { resolve, reject };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test("resolves the route with the module's default export", async () => {
|
|
12
|
+
const component = { name: "HomeView" };
|
|
13
|
+
const { resolve, reject } = callRoute(lazyRoute(() => Promise.resolve({ default: component })));
|
|
14
|
+
|
|
15
|
+
await vi.waitFor(() => expect(resolve).toHaveBeenCalledOnce());
|
|
16
|
+
expect(resolve).toHaveBeenCalledWith({ component });
|
|
17
|
+
expect(reject).not.toHaveBeenCalled();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("rejects the route when the chunk fails to load, releasing the router", async () => {
|
|
21
|
+
// Without this the router keeps allowPageChange === false forever and the view freezes.
|
|
22
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
23
|
+
const { resolve, reject } = callRoute(
|
|
24
|
+
lazyRoute(() => Promise.reject(new Error("chunk load failed"))),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
await vi.waitFor(() => expect(reject).toHaveBeenCalledOnce());
|
|
28
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
29
|
+
consoleError.mockRestore();
|
|
30
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { expect, test } from "vite-plus/test";
|
|
2
|
+
import { installNavigationGuard } from "../src/shared/composables/useNavigationGuard.js";
|
|
3
|
+
|
|
4
|
+
function makeRouter() {
|
|
5
|
+
const calls: string[] = [];
|
|
6
|
+
const router = {
|
|
7
|
+
allowPageChange: true,
|
|
8
|
+
navigate(url: string) {
|
|
9
|
+
calls.push(`navigate:${url}`);
|
|
10
|
+
},
|
|
11
|
+
back() {
|
|
12
|
+
calls.push("back");
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
return { router, calls };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("drops a navigate issued while a transition is still in flight", () => {
|
|
19
|
+
const { router, calls } = makeRouter();
|
|
20
|
+
installNavigationGuard(router as never);
|
|
21
|
+
|
|
22
|
+
router.allowPageChange = false;
|
|
23
|
+
router.navigate("/home/feature/1/");
|
|
24
|
+
|
|
25
|
+
expect(calls).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("drops a back issued while a transition is still in flight", () => {
|
|
29
|
+
const { router, calls } = makeRouter();
|
|
30
|
+
installNavigationGuard(router as never);
|
|
31
|
+
|
|
32
|
+
router.allowPageChange = false;
|
|
33
|
+
router.back();
|
|
34
|
+
|
|
35
|
+
expect(calls).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("lets navigation through while the router is idle", () => {
|
|
39
|
+
const { router, calls } = makeRouter();
|
|
40
|
+
installNavigationGuard(router as never);
|
|
41
|
+
|
|
42
|
+
router.navigate("/home/feature/1/");
|
|
43
|
+
router.back();
|
|
44
|
+
|
|
45
|
+
expect(calls).toEqual(["navigate:/home/feature/1/", "back"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("still allows Framework7's own recursive calls after it locks the router", () => {
|
|
49
|
+
// back() re-enters itself with { force: true }, and navigate() re-enters for named routes, both
|
|
50
|
+
// after allowPageChange is already false. Dropping those would break normal navigation.
|
|
51
|
+
const calls: string[] = [];
|
|
52
|
+
const router = {
|
|
53
|
+
allowPageChange: true,
|
|
54
|
+
navigate(url: string) {
|
|
55
|
+
calls.push(`navigate:${url}`);
|
|
56
|
+
router.allowPageChange = false;
|
|
57
|
+
if (url === "outer") router.navigate("inner");
|
|
58
|
+
},
|
|
59
|
+
back() {
|
|
60
|
+
calls.push("back");
|
|
61
|
+
router.allowPageChange = false;
|
|
62
|
+
router.navigate("from-back");
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
installNavigationGuard(router as never);
|
|
66
|
+
|
|
67
|
+
router.navigate("outer");
|
|
68
|
+
router.allowPageChange = true;
|
|
69
|
+
router.back();
|
|
70
|
+
|
|
71
|
+
expect(calls).toEqual(["navigate:outer", "navigate:inner", "back", "navigate:from-back"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("installing twice does not wrap the router twice", () => {
|
|
75
|
+
const { router, calls } = makeRouter();
|
|
76
|
+
installNavigationGuard(router as never);
|
|
77
|
+
installNavigationGuard(router as never);
|
|
78
|
+
|
|
79
|
+
router.navigate("/demo/");
|
|
80
|
+
|
|
81
|
+
expect(calls).toEqual(["navigate:/demo/"]);
|
|
82
|
+
});
|
|
@@ -91,5 +91,13 @@ export default defineConfig({
|
|
|
91
91
|
server: { port: 5173 },
|
|
92
92
|
build: { target: "esnext" },
|
|
93
93
|
lint: { options: { typeAware: false } },
|
|
94
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The unplugin declaration files are committed - vue-tsc has no idea what `ref` or an F7
|
|
96
|
+
* component is without them - but the generator rewrites them on every run, and what it emits is
|
|
97
|
+
* not what oxfmt emits. Formatting them means a build and a commit hook take turns rewriting a
|
|
98
|
+
* file no human wrote and no human can keep formatted.
|
|
99
|
+
*/
|
|
100
|
+
fmt: {
|
|
101
|
+
ignorePatterns: ["**/auto-imports.d.ts", "**/components.d.ts"],
|
|
102
|
+
},
|
|
95
103
|
});
|