@assembly-js/app-foundation 0.7.3 → 0.8.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 CHANGED
@@ -19,6 +19,11 @@ 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.8.0
23
+ - [new] `useUrlState('id')` from `@assembly-js/app-foundation/client` keeps a record selection in the URL search string, so a record panel is deep-linkable without its own route.
24
+ - [new] `bun run build` runs `check:record-urls`, which fails when a record panel is opened from React state alone, with neither the helper nor a dynamic route. It only runs on apps that carry the inner-location skill.
25
+ - [guidance] `assembly-app-inner-location` says selection state lives in the URL and a record opened from a list must load cold from its URL.
26
+
22
27
  ## 0.7.3
23
28
  - [guidance] The build agent's auth guide is now its own section in `CLAUDE.md` instead of sitting under "Design system", and the rule for choosing between `getAssembly()` and `getInstallerAssembly()` is stated once in `assembly-route-auth` rather than repeated in the notifications, automations and internal-users skills.
24
29
 
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when a record opened from a list (a Dialog, Sheet, Drawer or
3
+ // detail panel) is driven by React state alone: the host URL never changes, so a
4
+ // reload or a shared link lands on the list instead of the record. The fix is
5
+ // either `useUrlState('id')` from `@assembly-js/app-foundation/client` or a
6
+ // dynamic route for the record on the internal surface. The match is a
7
+ // heuristic on a `selected*`-style `useState` that feeds a panel-like element.
8
+ // Dependency free on purpose: it runs inside the generated app's sandbox before
9
+ // any deps beyond Node are guaranteed.
10
+ //
11
+ // Inert unless the inner-location skill was written into the app: that skill ships
12
+ // only when the deep-link flag is on, while the layout mounts <LocationReporter />
13
+ // in every build, so the reporter alone cannot tell an off-flag app apart.
14
+ // On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
15
+ // update never rewrites how an app navigates.
16
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
17
+ import { join, relative, sep } from 'node:path';
18
+
19
+ const ROOT = process.cwd();
20
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
21
+ const APP_DIR = join(ROOT, 'app');
22
+ const MANIFEST = join(ROOT, '.assembly', 'app.json');
23
+ const INNER_LOCATION_SKILL = join(ROOT, '.claude', 'skills', 'assembly-app-inner-location', 'SKILL.md');
24
+ const SOURCE_FILE = /\.(tsx|jsx)$/;
25
+ const PAGE_FILE = /^page\.(tsx|ts|jsx|js)$/;
26
+
27
+ // `const [selectedRequest, setSelectedRequest] = useState(...)`; the setter name is not trusted.
28
+ const USE_STATE = /const\s+\[\s*([A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\b/g;
29
+ const RECORD_SELECTION = /^(?:selected|active|viewing|viewed|current|detail|open(?:ed)?)[A-Z_]/;
30
+ // Selection of something that is not a record, or that is genuinely transient.
31
+ const NOT_A_RECORD = /(?:Tab|Filter|Status|Sort|View|Option|Date|Range|Column|Mode|Step|Page|Menu)s?$|^(?:confirm|delete|remove|menu|pending)/i;
32
+ const PANEL_ELEMENT = /<\w*(?:Dialog|Sheet|Drawer|Panel|Modal|Detail)\w*[\s>/]/;
33
+ const OPT_OUT = /record-urls:\s*transient/;
34
+
35
+ function sourceFiles(dir) {
36
+ const out = [];
37
+ if (!existsSync(dir)) return out;
38
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
39
+ const full = join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ if (entry.name === 'node_modules' || entry.name === '.next') continue;
42
+ out.push(...sourceFiles(full));
43
+ } else if (/\.(tsx|ts|jsx|js|mjs)$/.test(entry.name)) {
44
+ out.push(full);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ // Prefixes of the client and notification surfaces; a dynamic route there does
51
+ // not give the internal dashboard a record URL.
52
+ function nonInternalPrefixes() {
53
+ const prefixes = new Set(['/c', '/n']);
54
+ try {
55
+ const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
56
+ for (const key of ['client', 'notification']) {
57
+ const p = manifest?.surfaces?.[key]?.path;
58
+ if (typeof p === 'string' && p.startsWith('/') && p !== '/') prefixes.add(p.replace(/\/+$/, ''));
59
+ }
60
+ } catch {
61
+ // defaults
62
+ }
63
+ return prefixes;
64
+ }
65
+
66
+ function routeForPageFile(file) {
67
+ const segments = relative(APP_DIR, file).split(sep).slice(0, -1);
68
+ const urlSegments = segments.filter(
69
+ (seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'),
70
+ );
71
+ return '/' + urlSegments.join('/');
72
+ }
73
+
74
+ if (!existsSync(APP_DIR)) process.exit(0);
75
+
76
+ const files = [...sourceFiles(APP_DIR), ...sourceFiles(join(ROOT, 'components'))];
77
+ const contents = new Map(files.map((f) => [f, readFileSync(f, 'utf8')]));
78
+
79
+ if (!existsSync(INNER_LOCATION_SKILL)) process.exit(0);
80
+
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
+ function escapeRe(s) {
91
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
+ }
93
+
94
+ const violations = [];
95
+ for (const file of files) {
96
+ if (!SOURCE_FILE.test(file)) continue;
97
+ const src = contents.get(file);
98
+ if (/\buseUrlState\b/.test(src) || OPT_OUT.test(src) || !PANEL_ELEMENT.test(src)) continue;
99
+ for (const match of src.matchAll(USE_STATE)) {
100
+ const name = match[1];
101
+ if (!RECORD_SELECTION.test(name) || NOT_A_RECORD.test(name)) continue;
102
+ // Declared and read somewhere else in the file, i.e. it feeds the panel.
103
+ const uses = src.match(new RegExp(`(?<![\\w$.])${escapeRe(name)}(?![\\w$])`, 'g')) ?? [];
104
+ if (uses.length < 3) continue;
105
+ violations.push(`${relative(ROOT, file).split(sep).join('/')} (useState: ${name})`);
106
+ }
107
+ }
108
+
109
+ if (violations.length > 0 && UPDATE_TURN) {
110
+ console.warn('');
111
+ console.warn('check-record-urls: record selection held in React state (this update turn is not failed for it):');
112
+ for (const v of violations) console.warn(' - ' + v);
113
+ console.warn('');
114
+ } else if (violations.length > 0) {
115
+ console.error('');
116
+ console.error('Record selection lives only in React state — the URL never changes when a record opens:');
117
+ console.error('');
118
+ for (const v of violations) console.error(' - ' + v);
119
+ console.error('');
120
+ console.error('A reload or a shared link lands on the list, not on the record. Fix one of:');
121
+ console.error(" - Replace the useState with `const [id, setId] = useUrlState('id')` from");
122
+ console.error(" '@assembly-js/app-foundation/client' and render the record from `id`;");
123
+ console.error(' a cold load with ?id= must open it, and Back must close it.');
124
+ console.error(' - Give the record its own route (app/requests/[id]/page.tsx) and link to it.');
125
+ console.error('A genuinely transient UI (confirm dialog, menu) can add the comment');
126
+ console.error('`// record-urls: transient` to the file.');
127
+ process.exit(1);
128
+ }
@@ -57,5 +57,5 @@ export const NON_CLIENT_EXPORTS = new Set([
57
57
  'redactUrl', 'replaceChildRows', 'requireAuth', 'requireCompanyScope', 'resolveFont',
58
58
  'resolveResource', 'sendThrottled', 'serializeBearerCookie', 'serializeBearerExpiryCookie', 'serializeCookieProbe',
59
59
  'setCookielessBearer', 'slackErrorCode', 'slackFindChannelId', 'slackListChannels',
60
- 'slackPostMessage', 'slackResolveMentions', 'snippetFromRequestBody', 'verifyBearer',
60
+ 'slackPostMessage', 'slackResolveMentions', 'snippetFromRequestBody', 'useUrlState', 'verifyBearer',
61
61
  ]);
package/dist/client.d.ts CHANGED
@@ -8,3 +8,4 @@ export * from './pagination.js';
8
8
  export * from './theme.js';
9
9
  export * from './resource-resolution.js';
10
10
  export { LocationReporter } from './location-reporter.js';
11
+ export { useUrlState } from './url-state.js';
package/dist/client.js CHANGED
@@ -25,3 +25,5 @@ export * from './theme.js';
25
25
  export * from './resource-resolution.js';
26
26
  // Browser-safe: the reporter pulls in no Node built-ins, unlike ./next.
27
27
  export { LocationReporter } from './location-reporter.js';
28
+ // Keeps a record selection in the search string so the reporter mirrors it to the host URL.
29
+ export { useUrlState } from './url-state.js';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The query string with `key` set to `value` (or removed when null), as a
3
+ * `?`-prefixed string or '' when nothing is left. Other params, including the
4
+ * host's embed-session ones, are carried through untouched.
5
+ */
6
+ export declare function withSearchParam(search: URLSearchParams, key: string, value: string | null): string;
7
+ export type SetUrlState = (value: string | null, opts?: {
8
+ replace?: boolean;
9
+ }) => void;
10
+ /**
11
+ * State that lives in the page's search string (`?id=abc`) instead of React
12
+ * state. The location reporter already mirrors the search string into the host
13
+ * URL, so a record panel driven by this hook is deep-linkable without its own
14
+ * route: a cold load with `?id=` reopens it and Back closes it.
15
+ *
16
+ * Plain Next navigation, so it behaves the same with no bridge present. The
17
+ * component must render under a Suspense boundary, as for any `useSearchParams`.
18
+ */
19
+ export declare function useUrlState(key: string): [string | null, SetUrlState];
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+ import { useCallback } from 'react';
3
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4
+ /**
5
+ * The query string with `key` set to `value` (or removed when null), as a
6
+ * `?`-prefixed string or '' when nothing is left. Other params, including the
7
+ * host's embed-session ones, are carried through untouched.
8
+ */
9
+ export function withSearchParam(search, key, value) {
10
+ const next = new URLSearchParams(search);
11
+ if (value === null || value === '')
12
+ next.delete(key);
13
+ else
14
+ next.set(key, value);
15
+ const qs = next.toString();
16
+ return qs ? `?${qs}` : '';
17
+ }
18
+ /**
19
+ * State that lives in the page's search string (`?id=abc`) instead of React
20
+ * state. The location reporter already mirrors the search string into the host
21
+ * URL, so a record panel driven by this hook is deep-linkable without its own
22
+ * route: a cold load with `?id=` reopens it and Back closes it.
23
+ *
24
+ * Plain Next navigation, so it behaves the same with no bridge present. The
25
+ * component must render under a Suspense boundary, as for any `useSearchParams`.
26
+ */
27
+ export function useUrlState(key) {
28
+ const pathname = usePathname();
29
+ const router = useRouter();
30
+ const searchParams = useSearchParams();
31
+ const value = searchParams.get(key);
32
+ const setValue = useCallback((next, opts) => {
33
+ const url = pathname + withSearchParam(new URLSearchParams(searchParams.toString()), key, next);
34
+ // Push by default so Back undoes the selection.
35
+ if (opts?.replace)
36
+ router.replace(url, { scroll: false });
37
+ else
38
+ router.push(url, { scroll: false });
39
+ }, [pathname, router, searchParams, key]);
40
+ return [value, setValue];
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -11,6 +11,10 @@ The host mirrors the app's inner page in its own address bar and can open the ap
11
11
  - Mount `<LocationReporter />` once, in `app/layout.tsx`, imported from `@assembly-js/app-foundation/client`. If it is already there, leave it.
12
12
  - Never write custom `location.report` or `location.onNavigate` code, and never hand-write a `LocationSync` component. The reporter already handles surface prefixes and route changes.
13
13
 
14
+ ## Records get a URL
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.
17
+
14
18
  ## Notifications deep-link to the record, not the app root
15
19
 
16
20
  When the app sends an email or in-product notification about a specific record (a document request, a submission), the destination is set by the notification's `ctaParams` and surface, not by `linkTo`. Pass `ctaParams` that identify the record (`ctaParams: { requestId: request.id }`) and render that record from an existing inner route of this app, one that works when opened cold. Follow `assembly-notifications` for the send-and-open pattern.