@assembly-js/app-foundation 0.10.0 → 0.12.0
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/CHANGELOG.md +7 -0
- package/bin/check-crons.mjs +56 -4
- package/bin/check-record-urls.mjs +154 -30
- package/package.json +1 -1
- package/skills/assembly-app-inner-location/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,13 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.12.0
|
|
23
|
+
- [behavior] `check:crons` on a platform update turn now only warns about a cron route the app already had without a schedule; a route added this turn, or one that lost its schedule, still fails.
|
|
24
|
+
|
|
25
|
+
## 0.11.0
|
|
26
|
+
- [behavior] `check:record-urls` now checks each surface on its own: a record opened on the client portal needs its own `/c` route or `useUrlState`, and an internal dynamic route no longer exempts it. It also catches a list/detail view switch held in `useState`, so a client's request page keeps its own URL that can be reloaded or shared.
|
|
27
|
+
- [guidance] `assembly-app-inner-location` says the record URL rule applies to the client portal too, with `app/c/[id]/page.tsx` or `?id=` through `useUrlState` as the two valid shapes.
|
|
28
|
+
|
|
22
29
|
## 0.10.0
|
|
23
30
|
- [behavior] A call to another app through the installer client or a raw `fetch` to `/v1/apps/...` made inside a client's request now carries that client, so the platform checks the client could open the other app and the other app sees a client whose calling app is this one. A client who cannot open it gets the platform's `viewer_cannot_open_app` refusal as an error value; staff requests, cron, webhooks and queues are unchanged and send no parent.
|
|
24
31
|
- [behavior] A cross-app call from a client's request whose session cannot be verified (exchange off, a rejected header or expired cookie) now throws the token-invalid error instead of running as the installing admin, and a cross-app call where the request cannot be read (`'use cache'`, prerender, `after()`) throws. Storage, connector, notification and AI proxy installer calls are untouched.
|
package/bin/check-crons.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// as warnings: those paths have no agent who can act on a failure, so failing
|
|
17
17
|
// them would only strand an app that already worked. A below-floor schedule
|
|
18
18
|
// the app shipped before the turn stays a warning even then (see
|
|
19
|
-
// baselineCronSchedules)
|
|
19
|
+
// baselineCronSchedules), as does a cron route the app shipped with no schedule
|
|
20
|
+
// at all (see baselineUnscheduledRoutes).
|
|
20
21
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
21
22
|
import { spawnSync } from 'node:child_process';
|
|
22
23
|
import { join, relative, sep } from 'node:path';
|
|
@@ -42,7 +43,8 @@ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
|
|
|
42
43
|
// Set by the turn program on a platform update turn, where the update itself is
|
|
43
44
|
// the mandate to fix whatever is wrong, so findings fail the build whether or
|
|
44
45
|
// not the turn touched the cron wiring.
|
|
45
|
-
const
|
|
46
|
+
const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
|
|
47
|
+
const STRICT = process.argv.includes('--strict') || UPDATE_TURN;
|
|
46
48
|
const CRON_WIRING_PATHS = ['vercel.json', 'app/api/cron/'];
|
|
47
49
|
|
|
48
50
|
function report(lines, { fatal }) {
|
|
@@ -92,6 +94,44 @@ function baselineCronSchedules() {
|
|
|
92
94
|
return schedules;
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
// Cron route files (repo-relative, posix) that existed at the turn baseline with
|
|
98
|
+
// no vercel.json entry invoking them. An update turn has no mandate to schedule
|
|
99
|
+
// work the app never ran, so these only warn. An unreadable baseline yields an
|
|
100
|
+
// empty set, which keeps every unscheduled route fatal.
|
|
101
|
+
function baselineUnscheduledRoutes() {
|
|
102
|
+
const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
|
|
103
|
+
const unscheduled = new Set();
|
|
104
|
+
if (!baseline || !UPDATE_TURN) return unscheduled;
|
|
105
|
+
const listing = spawnSync('git', ['ls-tree', '-r', '--name-only', baseline, '--', 'app'], {
|
|
106
|
+
cwd: ROOT,
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
});
|
|
109
|
+
if (listing.status !== 0) return unscheduled;
|
|
110
|
+
// Route groups can hold cron routes too, so list all of app/ and filter by the URL served.
|
|
111
|
+
const routePaths = listing.stdout
|
|
112
|
+
.split('\n')
|
|
113
|
+
.filter((p) => ROUTE_FILE.test(p.split('/').pop()))
|
|
114
|
+
.filter((p) => ('/' + urlSegments(p.split('/').slice(1)).join('/') + '/').startsWith(CRON_ROUTE_PREFIX));
|
|
115
|
+
const shown = spawnSync('git', ['show', `${baseline}:vercel.json`], { cwd: ROOT, encoding: 'utf8' });
|
|
116
|
+
let baselineCrons = [];
|
|
117
|
+
if (shown.status === 0) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(shown.stdout).crons;
|
|
120
|
+
if (Array.isArray(parsed)) baselineCrons = parsed;
|
|
121
|
+
} catch {
|
|
122
|
+
// An unparseable baseline file scheduled nothing.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const scheduledPaths = baselineCrons
|
|
126
|
+
.filter((c) => typeof c?.path === 'string')
|
|
127
|
+
.map((c) => c.path.replace(/\/+$/, '').split('/').filter(Boolean));
|
|
128
|
+
for (const path of routePaths) {
|
|
129
|
+
const segments = urlSegments(path.split('/').slice(1));
|
|
130
|
+
if (!scheduledPaths.some((pathSegments) => routeServes(segments, pathSegments))) unscheduled.add(path);
|
|
131
|
+
}
|
|
132
|
+
return unscheduled;
|
|
133
|
+
}
|
|
134
|
+
|
|
95
135
|
function cronKey(entry) {
|
|
96
136
|
return `${entry?.path}\n${typeof entry?.schedule === 'string' ? entry.schedule.trim() : ''}`;
|
|
97
137
|
}
|
|
@@ -146,8 +186,11 @@ function routeFiles(dir) {
|
|
|
146
186
|
// The URL segments a route file serves; route groups `(group)` and parallel
|
|
147
187
|
// slots `@slot` never appear in the URL.
|
|
148
188
|
function urlSegmentsForRouteFile(file) {
|
|
149
|
-
return relative(APP_DIR, file)
|
|
150
|
-
|
|
189
|
+
return urlSegments(relative(APP_DIR, file).split(sep));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function urlSegments(pathParts) {
|
|
193
|
+
return pathParts
|
|
151
194
|
.slice(0, -1)
|
|
152
195
|
.filter((seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'));
|
|
153
196
|
}
|
|
@@ -259,6 +302,7 @@ const entries = Array.isArray(crons) ? crons : [];
|
|
|
259
302
|
const coveredRoutes = new Set();
|
|
260
303
|
const shippedSchedules = baselineCronSchedules();
|
|
261
304
|
const preexistingIntervals = [];
|
|
305
|
+
const unscheduledAtBaseline = baselineUnscheduledRoutes();
|
|
262
306
|
|
|
263
307
|
entries.forEach((entry, i) => {
|
|
264
308
|
const label = `vercel.json crons[${i}]`;
|
|
@@ -296,6 +340,14 @@ entries.forEach((entry, i) => {
|
|
|
296
340
|
for (const route of cronRoutes) {
|
|
297
341
|
const url = '/' + route.segments.join('/');
|
|
298
342
|
if (!coveredRoutes.has(route.file)) {
|
|
343
|
+
const rel = relative(ROOT, route.file).split(sep).join('/');
|
|
344
|
+
if (unscheduledAtBaseline.has(rel)) {
|
|
345
|
+
preexistingIntervals.push(
|
|
346
|
+
`${rel} serves ${url} but no vercel.json cron entry invokes it. The app shipped this route unscheduled, ` +
|
|
347
|
+
`and the update does not add a schedule, so leave it as it is.`,
|
|
348
|
+
);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
299
351
|
problems.push(
|
|
300
352
|
`${relative(ROOT, route.file)} serves ${url} but no vercel.json cron entry invokes it — it will never run ` +
|
|
301
353
|
`after publish. Add { "path": "${url}", "schedule": "*/30 * * * *" } to "crons" or remove the route.`,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Fails the build when a record opened from a list (a Dialog, Sheet, Drawer
|
|
3
|
-
// detail panel) is driven by React state alone: the
|
|
4
|
-
// reload or a shared link lands on the list instead
|
|
5
|
-
// either `useUrlState('id')` from
|
|
6
|
-
// dynamic route for the record on the
|
|
7
|
-
// heuristic on a `selected*`-style
|
|
2
|
+
// Fails the build when a record opened from a list (a Dialog, Sheet, Drawer,
|
|
3
|
+
// detail panel or a list/detail view switch) is driven by React state alone: the
|
|
4
|
+
// host URL never changes, so a reload or a shared link lands on the list instead
|
|
5
|
+
// of the record. The fix is either `useUrlState('id')` from
|
|
6
|
+
// `@assembly-js/app-foundation/client` or a dynamic route for the record on the
|
|
7
|
+
// surface that renders it. The match is a heuristic on a `selected*`-style
|
|
8
|
+
// `useState` that feeds a panel-like element, or a `useState<View>` whose type
|
|
9
|
+
// has a detail-like variant carrying an `id`.
|
|
8
10
|
// Dependency free on purpose: it runs inside the generated app's sandbox before
|
|
9
11
|
// any deps beyond Node are guaranteed.
|
|
10
12
|
//
|
|
@@ -14,7 +16,7 @@
|
|
|
14
16
|
// On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
|
|
15
17
|
// update never rewrites how an app navigates.
|
|
16
18
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
17
|
-
import { join, relative, sep } from 'node:path';
|
|
19
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
18
20
|
|
|
19
21
|
const ROOT = process.cwd();
|
|
20
22
|
const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
|
|
@@ -23,13 +25,24 @@ const MANIFEST = join(ROOT, '.assembly', 'app.json');
|
|
|
23
25
|
const INNER_LOCATION_SKILL = join(ROOT, '.claude', 'skills', 'assembly-app-inner-location', 'SKILL.md');
|
|
24
26
|
const SOURCE_FILE = /\.(tsx|jsx)$/;
|
|
25
27
|
const PAGE_FILE = /^page\.(tsx|ts|jsx|js)$/;
|
|
28
|
+
const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js'];
|
|
29
|
+
const IMPORT_SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]/g;
|
|
26
30
|
|
|
27
31
|
// `const [selectedRequest, setSelectedRequest] = useState(...)`; the setter name is not trusted.
|
|
28
32
|
const USE_STATE = /const\s+\[\s*([A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\b/g;
|
|
29
33
|
const RECORD_SELECTION = /^(?:selected|active|viewing|viewed|current|detail|open(?:ed)?)[A-Z_]/;
|
|
30
34
|
// Selection of something that is not a record, or that is genuinely transient.
|
|
31
|
-
const
|
|
35
|
+
const TRANSIENT_NAME = /^(?:confirm|delete|remove|menu|pending)/i;
|
|
36
|
+
const NOT_A_RECORD = new RegExp(
|
|
37
|
+
`(?:Tab|Filter|Status|Sort|View|Option|Date|Range|Column|Mode|Step|Page|Menu)s?$|${TRANSIENT_NAME.source}`,
|
|
38
|
+
'i',
|
|
39
|
+
);
|
|
32
40
|
const PANEL_ELEMENT = /<\w*(?:Dialog|Sheet|Drawer|Panel|Modal|Detail)\w*[\s>/]/;
|
|
41
|
+
// `const [view, setView] = useState<View>(...)`; the generic is read by scanning, since it can nest.
|
|
42
|
+
const TYPED_USE_STATE = /const\s+\[\s*([A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\s*</g;
|
|
43
|
+
// A view switch that can show one record: a variant naming a detail-like view, and an `id` field.
|
|
44
|
+
const DETAIL_VARIANT = /['"`](?:detail|show|view|open|record|item)['"`]/i;
|
|
45
|
+
const ID_FIELD = /\bid\??\s*:/;
|
|
33
46
|
const OPT_OUT = /record-urls:\s*transient/;
|
|
34
47
|
|
|
35
48
|
function sourceFiles(dir) {
|
|
@@ -47,15 +60,15 @@ function sourceFiles(dir) {
|
|
|
47
60
|
return out;
|
|
48
61
|
}
|
|
49
62
|
|
|
50
|
-
// Prefixes of the client and notification surfaces; a dynamic route
|
|
51
|
-
// not give
|
|
52
|
-
function
|
|
53
|
-
const prefixes = new Set(['/c', '/n']);
|
|
63
|
+
// Prefixes of the client and notification surfaces; a dynamic route on one
|
|
64
|
+
// surface does not give another surface a record URL.
|
|
65
|
+
function surfacePrefixes() {
|
|
66
|
+
const prefixes = { client: new Set(['/c']), notification: new Set(['/n']) };
|
|
54
67
|
try {
|
|
55
68
|
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
|
|
56
69
|
for (const key of ['client', 'notification']) {
|
|
57
70
|
const p = manifest?.surfaces?.[key]?.path;
|
|
58
|
-
if (typeof p === 'string' && p.startsWith('/') && p !== '/') prefixes.add(p.replace(/\/+$/, ''));
|
|
71
|
+
if (typeof p === 'string' && p.startsWith('/') && p !== '/') prefixes[key].add(p.replace(/\/+$/, ''));
|
|
59
72
|
}
|
|
60
73
|
} catch {
|
|
61
74
|
// defaults
|
|
@@ -63,6 +76,10 @@ function nonInternalPrefixes() {
|
|
|
63
76
|
return prefixes;
|
|
64
77
|
}
|
|
65
78
|
|
|
79
|
+
function underPrefix(route, prefixes) {
|
|
80
|
+
return [...prefixes].some((p) => route === p || route.startsWith(`${p}/`));
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
function routeForPageFile(file) {
|
|
67
84
|
const segments = relative(APP_DIR, file).split(sep).slice(0, -1);
|
|
68
85
|
const urlSegments = segments.filter(
|
|
@@ -78,31 +95,133 @@ const contents = new Map(files.map((f) => [f, readFileSync(f, 'utf8')]));
|
|
|
78
95
|
|
|
79
96
|
if (!existsSync(INNER_LOCATION_SKILL)) process.exit(0);
|
|
80
97
|
|
|
81
|
-
const prefixes = nonInternalPrefixes();
|
|
82
|
-
const hasInternalRecordRoute = files.some((f) => {
|
|
83
|
-
if (!PAGE_FILE.test(f.split(sep).pop())) return false;
|
|
84
|
-
const route = routeForPageFile(f);
|
|
85
|
-
if (!route.includes('[')) return false;
|
|
86
|
-
return ![...prefixes].some((p) => route === p || route.startsWith(`${p}/`));
|
|
87
|
-
});
|
|
88
|
-
if (hasInternalRecordRoute) process.exit(0);
|
|
89
|
-
|
|
90
98
|
function escapeRe(s) {
|
|
91
99
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
function relPath(file) {
|
|
103
|
+
return relative(ROOT, file).split(sep).join('/');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveImport(from, spec) {
|
|
107
|
+
let base;
|
|
108
|
+
if (spec.startsWith('./') || spec.startsWith('../')) base = join(dirname(from), spec);
|
|
109
|
+
else if (spec.startsWith('@/')) base = join(ROOT, spec.slice(2));
|
|
110
|
+
else return null;
|
|
111
|
+
const candidates = [
|
|
112
|
+
base,
|
|
113
|
+
...RESOLVE_EXTENSIONS.map((ext) => base + ext),
|
|
114
|
+
...RESOLVE_EXTENSIONS.map((ext) => join(base, 'index' + ext)),
|
|
115
|
+
];
|
|
116
|
+
return candidates.find((c) => contents.has(c)) ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Files reachable from `roots` through relative and `@/` imports. An import the
|
|
120
|
+
// regex misses (a barrel re-export, a computed path) only shrinks the set.
|
|
121
|
+
function reachableFrom(roots) {
|
|
122
|
+
const seen = new Set(roots);
|
|
123
|
+
const queue = [...roots];
|
|
124
|
+
while (queue.length) {
|
|
125
|
+
const file = queue.pop();
|
|
126
|
+
for (const m of contents.get(file).matchAll(IMPORT_SPECIFIER)) {
|
|
127
|
+
const target = resolveImport(file, m[1]);
|
|
128
|
+
if (target && !seen.has(target)) {
|
|
129
|
+
seen.add(target);
|
|
130
|
+
queue.push(target);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return seen;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The text between the `<` before `start` and its matching `>`, skipping `=>`.
|
|
138
|
+
function genericArgument(src, start) {
|
|
139
|
+
let depth = 1;
|
|
140
|
+
for (let i = start; i < src.length; i++) {
|
|
141
|
+
const ch = src[i];
|
|
142
|
+
if (ch === '<') depth++;
|
|
143
|
+
else if (ch === '>' && src[i - 1] !== '=' && --depth === 0) return src.slice(start, i);
|
|
144
|
+
}
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The body of `type Name = …` or `interface Name {…}` declared in this file.
|
|
149
|
+
function localTypeBody(src, name) {
|
|
150
|
+
const n = escapeRe(name);
|
|
151
|
+
const decl = new RegExp(`\\btype\\s+${n}\\b(?:<[^=]*>)?\\s*=|\\binterface\\s+${n}\\b[^{]*`).exec(src);
|
|
152
|
+
if (!decl) return '';
|
|
153
|
+
const isInterface = decl[0].startsWith('interface');
|
|
154
|
+
const start = decl.index + decl[0].length;
|
|
155
|
+
let depth = 0;
|
|
156
|
+
for (let i = start; i < src.length; i++) {
|
|
157
|
+
const ch = src[i];
|
|
158
|
+
if ('{(['.includes(ch)) depth++;
|
|
159
|
+
else if ('})]'.includes(ch)) {
|
|
160
|
+
depth--;
|
|
161
|
+
if (depth === 0 && isInterface) return src.slice(start, i + 1);
|
|
162
|
+
} else if (depth === 0 && !isInterface && ch === ';') {
|
|
163
|
+
return src.slice(start, i);
|
|
164
|
+
} else if (depth === 0 && !isInterface && ch === '\n' && src.slice(start, i).trim() && !/^\s*[|&]/.test(src.slice(i + 1))) {
|
|
165
|
+
return src.slice(start, i);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return src.slice(start);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isDetailViewType(src, typeText) {
|
|
172
|
+
let text = typeText;
|
|
173
|
+
for (const ident of new Set(typeText.match(/\b[A-Z][\w$]*\b/g) ?? [])) text += '\n' + localTypeBody(src, ident);
|
|
174
|
+
return ID_FIELD.test(text) && DETAIL_VARIANT.test(text);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const prefixes = surfacePrefixes();
|
|
178
|
+
const clientPages = [];
|
|
179
|
+
const internalPages = [];
|
|
180
|
+
let hasClientRecordRoute = false;
|
|
181
|
+
let hasInternalRecordRoute = false;
|
|
182
|
+
for (const f of files) {
|
|
183
|
+
if (!PAGE_FILE.test(f.split(sep).pop())) continue;
|
|
184
|
+
const route = routeForPageFile(f);
|
|
185
|
+
if (underPrefix(route, prefixes.client)) {
|
|
186
|
+
clientPages.push(f);
|
|
187
|
+
if (route.includes('[')) hasClientRecordRoute = true;
|
|
188
|
+
} else if (!underPrefix(route, prefixes.notification)) {
|
|
189
|
+
internalPages.push(f);
|
|
190
|
+
if (route.includes('[')) hasInternalRecordRoute = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const clientReachable = reachableFrom(clientPages);
|
|
194
|
+
const internalReachable = reachableFrom(internalPages);
|
|
195
|
+
|
|
196
|
+
// A route exempts a file only when every surface that renders it has one. A file
|
|
197
|
+
// no page reaches is judged as internal, as it was before the check followed imports.
|
|
198
|
+
function exemptByRoute(file) {
|
|
199
|
+
const onClient = clientReachable.has(file);
|
|
200
|
+
const onInternal = internalReachable.has(file) || !onClient;
|
|
201
|
+
return (!onClient || hasClientRecordRoute) && (!onInternal || hasInternalRecordRoute);
|
|
202
|
+
}
|
|
203
|
+
|
|
94
204
|
const violations = [];
|
|
95
205
|
for (const file of files) {
|
|
96
206
|
if (!SOURCE_FILE.test(file)) continue;
|
|
97
207
|
const src = contents.get(file);
|
|
98
|
-
if (/\buseUrlState\b/.test(src) || OPT_OUT.test(src) ||
|
|
99
|
-
|
|
208
|
+
if (/\buseUrlState\b/.test(src) || OPT_OUT.test(src) || exemptByRoute(file)) continue;
|
|
209
|
+
const where = clientReachable.has(file) ? `${relPath(file)} [client surface]` : relPath(file);
|
|
210
|
+
if (PANEL_ELEMENT.test(src)) {
|
|
211
|
+
for (const match of src.matchAll(USE_STATE)) {
|
|
212
|
+
const name = match[1];
|
|
213
|
+
if (!RECORD_SELECTION.test(name) || NOT_A_RECORD.test(name)) continue;
|
|
214
|
+
// Declared and read somewhere else in the file, i.e. it feeds the panel.
|
|
215
|
+
const uses = src.match(new RegExp(`(?<![\\w$.])${escapeRe(name)}(?![\\w$])`, 'g')) ?? [];
|
|
216
|
+
if (uses.length < 3) continue;
|
|
217
|
+
violations.push(`${where} (useState: ${name})`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const match of src.matchAll(TYPED_USE_STATE)) {
|
|
100
221
|
const name = match[1];
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (uses.length < 3) continue;
|
|
105
|
-
violations.push(`${relative(ROOT, file).split(sep).join('/')} (useState: ${name})`);
|
|
222
|
+
if (TRANSIENT_NAME.test(name)) continue;
|
|
223
|
+
if (!isDetailViewType(src, genericArgument(src, match.index + match[0].length))) continue;
|
|
224
|
+
violations.push(`${where} (useState view switch: ${name})`);
|
|
106
225
|
}
|
|
107
226
|
}
|
|
108
227
|
|
|
@@ -121,7 +240,12 @@ if (violations.length > 0 && UPDATE_TURN) {
|
|
|
121
240
|
console.error(" - Replace the useState with `const [id, setId] = useUrlState('id')` from");
|
|
122
241
|
console.error(" '@assembly-js/app-foundation/client' and render the record from `id`;");
|
|
123
242
|
console.error(' a cold load with ?id= must open it, and Back must close it.');
|
|
124
|
-
console.error(' - Give the record its own route
|
|
243
|
+
console.error(' - Give the record its own route on the surface that lists it and link to it:');
|
|
244
|
+
console.error(' app/requests/[id]/page.tsx on the internal surface.');
|
|
245
|
+
console.error('A file marked [client surface] needs the same fix there: call');
|
|
246
|
+
console.error("`useUrlState('requestId')` in the client page component instead of a view state,");
|
|
247
|
+
console.error('or add app/c/[id]/page.tsx and link to it from the list. A cold load of that URL');
|
|
248
|
+
console.error('must open the record and Back must close it.');
|
|
125
249
|
console.error('A genuinely transient UI (confirm dialog, menu) can add the comment');
|
|
126
250
|
console.error('`// record-urls: transient` to the file.');
|
|
127
251
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ The host mirrors the app's inner page in its own address bar and can open the ap
|
|
|
13
13
|
|
|
14
14
|
## Records get a URL
|
|
15
15
|
|
|
16
|
-
Keep which record is open in the URL, not in `useState`: `const [id, setId] = useUrlState('id')` from `@assembly-js/app-foundation/client` for a panel, Sheet or Dialog, or a route like `app/requests/[id]/page.tsx`. A record opened from a list must load cold from its URL and Back must close it. `bun run build` fails `check:record-urls` for a `selectedId`-style `useState` feeding a record panel; use the helper or a route to fix it.
|
|
16
|
+
Keep which record is open in the URL, not in `useState`: `const [id, setId] = useUrlState('id')` from `@assembly-js/app-foundation/client` for a panel, Sheet or Dialog, or a route like `app/requests/[id]/page.tsx`. A record opened from a list must load cold from its URL and Back must close it. `bun run build` fails `check:record-urls` for a `selectedId`-style `useState` feeding a record panel; use the helper or a route to fix it. This applies to the client surface (`/c`) as much as the internal one: a record a client opens from a list is `app/c/[id]/page.tsx` or `?id=` through `useUrlState`, never a `useState<View>` that switches between list and detail; the check fails that too.
|
|
17
17
|
|
|
18
18
|
## Notifications deep-link to the record, not the app root
|
|
19
19
|
|