@akanjs/devkit 3.0.0-beta.25 → 3.0.0-beta.26

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.
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { SOURCE_EXTS } from "../akanApp/devHostPolicy";
3
4
  import type { App } from "../commandDecorators";
@@ -17,6 +18,21 @@ interface FileFacts {
17
18
  imports: ScannedImport[];
18
19
  }
19
20
 
21
+ interface Lookup<T> {
22
+ epoch: number;
23
+ promise: Promise<T>;
24
+ found?: boolean;
25
+ }
26
+
27
+ interface DiscoveryWalk {
28
+ visiting: Set<string>;
29
+ //? Walked below a file the walk was already inside (an import cycle): their result lacks what that file reaches.
30
+ partial: Set<string>;
31
+ //? Every file's result within this walk, partial ones included: the file the cycle went back to is an ancestor here
32
+ //? and brings the rest itself, and re-walking each path through a dense cycle grows exponentially.
33
+ memo: Map<string, Set<string>>;
34
+ }
35
+
20
36
  const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath) && !AKANJS_NODE_MODULE_RE.test(absPath);
21
37
 
22
38
  /** `"use client"` discovery over the import graph (dynamic imports included), flattening barrels as the bundler does. */
@@ -25,15 +41,13 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
25
41
  #resolvePackage: PackageResolver;
26
42
  #analyzer: BarrelAnalyzer;
27
43
  #tsTranspiler = new Bun.Transpiler({ loader: "tsx" });
28
- #fileExistsCache = new Map<string, Promise<boolean>>();
44
+ #fileExistsCache = new Map<string, Lookup<boolean>>();
29
45
  // Facts, never source text: this instance lives as long as the builder process, so texts would pin every file walked.
30
46
  #factsCache = new Map<string, Promise<FileFacts | null>>();
31
- #resolvedFileCache = new Map<string, Promise<string | null>>();
32
- #resolvedSpecifierCache = new Map<string, Promise<string | null>>();
47
+ #resolvedFileCache = new Map<string, Lookup<string | null>>();
48
+ #resolvedSpecifierCache = new Map<string, Lookup<string | null>>();
33
49
  #reachableEntriesCache = new Map<string, Set<string>>();
34
- #missingFiles = new Set<string>();
35
- #unresolvedPaths = new Set<string>();
36
- #unresolvedSpecifiers = new Set<string>();
50
+ #readFiles = new Set<string>();
37
51
  //? Bumped by every invalidation: a walk that began before one computed from facts it may have read stale, so it
38
52
  //? answers its caller but caches nothing (a registry check walks this instance beside the slow lane's route builds).
39
53
  #epoch = 0;
@@ -51,48 +65,61 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
51
65
  async discover(seeds: string[]): Promise<string[]> {
52
66
  const entries = new Set<string>();
53
67
  for (const seed of seeds) {
54
- for (const entry of await this.#discoverFromFile(seed, new Set())) entries.add(entry);
68
+ const walk: DiscoveryWalk = { visiting: new Set(), partial: new Set(), memo: new Map() };
69
+ for (const entry of await this.#discoverFromFile(seed, walk)) entries.add(entry);
55
70
  }
56
71
  return Array.from(entries).sort();
57
72
  }
58
73
 
59
74
  invalidate(files: string[]): void {
75
+ if (files.length === 0) return;
76
+ let moved = false;
60
77
  for (const file of files) {
61
78
  const absPath = path.resolve(file);
79
+ const exists = fs.existsSync(absPath);
80
+ if (exists !== this.#readFiles.has(absPath)) moved = true;
81
+ if (!exists) this.#readFiles.delete(absPath);
62
82
  this.#factsCache.delete(absPath);
63
83
  this.#fileExistsCache.delete(absPath);
64
- this.#reachableEntriesCache.delete(absPath);
65
84
  }
66
- if (files.length === 0) return;
67
85
  this.#epoch += 1;
68
86
  // Reachable-entry results are transitive, so a changed child invalidates every ancestor.
69
87
  this.#reachableEntriesCache.clear();
70
- this.#forgetMissing();
88
+ //? A file gone, or one never read, can leave a found resolution on its old target (`Chart.tsx` to
89
+ //? `Chart/index.tsx`), so a move drops them all; a save of a file already read keeps them (a walk re-resolving
90
+ //? every package cost 3x). Misses lapse with the epoch on their own (#lookup).
91
+ if (!moved) return;
92
+ this.#fileExistsCache.clear();
93
+ this.#resolvedFileCache.clear();
94
+ this.#resolvedSpecifierCache.clear();
71
95
  }
72
96
 
73
- // Negatives are keyed by extension-less path or `dir\0specifier`, which a new file's path cannot reach, so all go.
74
- #forgetMissing(): void {
75
- for (const key of this.#missingFiles) this.#fileExistsCache.delete(key);
76
- for (const key of this.#unresolvedPaths) this.#resolvedFileCache.delete(key);
77
- for (const key of this.#unresolvedSpecifiers) this.#resolvedSpecifierCache.delete(key);
78
- this.#missingFiles.clear();
79
- this.#unresolvedPaths.clear();
80
- this.#unresolvedSpecifiers.clear();
97
+ //? A found answer outlives later epochs; a miss, or a lookup still in flight, answers only walks of its own epoch, so
98
+ //? a file created meanwhile is looked up again.
99
+ #lookup<T>(cache: Map<string, Lookup<T>>, key: string, run: () => Promise<T>, isFound: (value: T) => boolean) {
100
+ const cached = cache.get(key);
101
+ if (cached && (cached.found || cached.epoch === this.#epoch)) return cached.promise;
102
+ const entry: Lookup<T> = { epoch: this.#epoch, promise: run() };
103
+ entry.promise.then(
104
+ (value) => {
105
+ entry.found = isFound(value);
106
+ },
107
+ () => {
108
+ entry.found = false;
109
+ },
110
+ );
111
+ cache.set(key, entry);
112
+ return entry.promise;
81
113
  }
82
114
 
83
115
  async #fileExists(p: string): Promise<boolean> {
84
116
  const absPath = path.resolve(p);
85
- let cached = this.#fileExistsCache.get(absPath);
86
- if (!cached) {
87
- cached = Bun.file(absPath)
88
- .exists()
89
- .then((exists) => {
90
- if (!exists) this.#missingFiles.add(absPath);
91
- return exists;
92
- });
93
- this.#fileExistsCache.set(absPath, cached);
94
- }
95
- return cached;
117
+ return await this.#lookup(
118
+ this.#fileExistsCache,
119
+ absPath,
120
+ () => Bun.file(absPath).exists(),
121
+ (exists) => exists,
122
+ );
96
123
  }
97
124
 
98
125
  #facts(file: string): Promise<FileFacts | null> {
@@ -104,6 +131,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
104
131
  .text()
105
132
  .catch(() => null);
106
133
  if (content === null) return null;
134
+ this.#readFiles.add(absPath);
107
135
  if (USE_CLIENT_RE.test(content)) return { isClientEntry: true, imports: [] };
108
136
  return { isClientEntry: false, imports: this.#scanImports(await this.#rewrite(content)) };
109
137
  })();
@@ -131,58 +159,71 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
131
159
 
132
160
  async #resolveFileCandidate(absPathNoExt: string): Promise<string | null> {
133
161
  const cacheKey = path.resolve(absPathNoExt);
134
- let cached = this.#resolvedFileCache.get(cacheKey);
135
- if (cached) return cached;
136
- cached = (async () => {
137
- if (await this.#fileExists(cacheKey)) return cacheKey;
138
- for (const ext of SOURCE_EXTS) {
139
- const f = `${cacheKey}${ext}`;
140
- if (await this.#fileExists(f)) return f;
141
- }
142
- for (const ext of SOURCE_EXTS) {
143
- const f = path.join(cacheKey, `index${ext}`);
144
- if (await this.#fileExists(f)) return f;
145
- }
146
- this.#unresolvedPaths.add(cacheKey);
147
- return null;
148
- })();
149
- this.#resolvedFileCache.set(cacheKey, cached);
150
- return cached;
162
+ return await this.#lookup(
163
+ this.#resolvedFileCache,
164
+ cacheKey,
165
+ async () => {
166
+ if (await this.#fileExists(cacheKey)) return cacheKey;
167
+ for (const ext of SOURCE_EXTS) {
168
+ const f = `${cacheKey}${ext}`;
169
+ if (await this.#fileExists(f)) return f;
170
+ }
171
+ for (const ext of SOURCE_EXTS) {
172
+ const f = path.join(cacheKey, `index${ext}`);
173
+ if (await this.#fileExists(f)) return f;
174
+ }
175
+ return null;
176
+ },
177
+ (file) => file !== null,
178
+ );
151
179
  }
152
180
 
153
181
  async #resolveSpecifier(spec: string, importerDir: string): Promise<string | null> {
154
182
  const cacheKey = `${importerDir}\0${spec}`;
155
- let cached = this.#resolvedSpecifierCache.get(cacheKey);
156
- if (cached) return cached;
157
- cached = (async () => {
158
- if (spec.startsWith(".") || spec.startsWith("/")) {
159
- const abs = spec.startsWith("/") ? spec : path.resolve(importerDir, spec);
160
- return this.#resolveFileCandidate(abs);
161
- }
162
- const pkg = await this.#resolvePackage(spec);
163
- if (pkg) return pkg.entryFile;
164
- this.#unresolvedSpecifiers.add(cacheKey);
165
- return null;
166
- })();
167
- this.#resolvedSpecifierCache.set(cacheKey, cached);
168
- return cached;
183
+ return await this.#lookup(
184
+ this.#resolvedSpecifierCache,
185
+ cacheKey,
186
+ async () => {
187
+ if (spec.startsWith(".") || spec.startsWith("/")) {
188
+ const abs = spec.startsWith("/") ? spec : path.resolve(importerDir, spec);
189
+ return await this.#resolveFileCandidate(abs);
190
+ }
191
+ const pkg = await this.#resolvePackage(spec);
192
+ return pkg ? pkg.entryFile : null;
193
+ },
194
+ (file) => file !== null,
195
+ );
169
196
  }
170
197
 
171
- async #discoverFromFile(file: string, visiting: Set<string>): Promise<Set<string>> {
198
+ async #discoverFromFile(file: string, walk: DiscoveryWalk): Promise<Set<string>> {
172
199
  const epoch = this.#epoch;
173
200
  const absPath = path.resolve(file);
174
201
  const cached = this.#reachableEntriesCache.get(absPath);
175
202
  if (cached) return new Set(cached);
176
- if (visiting.has(absPath) || shouldSkipNodeModule(absPath)) return new Set();
203
+ const memo = walk.memo.get(absPath);
204
+ if (memo) {
205
+ //? Reused from another path: whatever this path is walking now inherits its gap and stays out of the cache.
206
+ if (walk.partial.has(absPath)) for (const onPath of walk.visiting) walk.partial.add(onPath);
207
+ return new Set(memo);
208
+ }
209
+ if (walk.visiting.has(absPath)) {
210
+ let below = false;
211
+ for (const onPath of walk.visiting) {
212
+ if (below) walk.partial.add(onPath);
213
+ else below = onPath === absPath;
214
+ }
215
+ return new Set();
216
+ }
217
+ if (shouldSkipNodeModule(absPath)) return new Set();
177
218
 
178
- visiting.add(absPath);
219
+ walk.visiting.add(absPath);
179
220
  const entries = new Set<string>();
180
221
  const facts = await this.#facts(absPath);
181
- if (!facts) return this.#finishDiscovery(absPath, visiting, entries, epoch);
222
+ if (!facts) return this.#finishDiscovery(absPath, walk, entries, epoch);
182
223
 
183
224
  if (facts.isClientEntry) {
184
225
  entries.add(absPath);
185
- return this.#finishDiscovery(absPath, visiting, entries, epoch);
226
+ return this.#finishDiscovery(absPath, walk, entries, epoch);
186
227
  }
187
228
 
188
229
  const importerDir = path.dirname(absPath);
@@ -193,15 +234,16 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
193
234
  const resolved = await this.#resolveSpecifier(spec, importerDir);
194
235
  if (!resolved) continue;
195
236
  if (shouldSkipNodeModule(resolved)) continue;
196
- for (const entry of await this.#discoverFromFile(resolved, visiting)) entries.add(entry);
237
+ for (const entry of await this.#discoverFromFile(resolved, walk)) entries.add(entry);
197
238
  }
198
239
 
199
- return this.#finishDiscovery(absPath, visiting, entries, epoch);
240
+ return this.#finishDiscovery(absPath, walk, entries, epoch);
200
241
  }
201
242
 
202
- #finishDiscovery(absPath: string, visiting: Set<string>, entries: Set<string>, epoch: number): Set<string> {
203
- visiting.delete(absPath);
204
- if (epoch === this.#epoch) this.#reachableEntriesCache.set(absPath, entries);
243
+ #finishDiscovery(absPath: string, walk: DiscoveryWalk, entries: Set<string>, epoch: number): Set<string> {
244
+ walk.visiting.delete(absPath);
245
+ walk.memo.set(absPath, entries);
246
+ if (epoch === this.#epoch && !walk.partial.has(absPath)) this.#reachableEntriesCache.set(absPath, entries);
205
247
  return new Set(entries);
206
248
  }
207
249
  }
@@ -0,0 +1,89 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { App } from "../commandDecorators";
6
+ import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
7
+ import { CsrDevPaths } from "./csrDevPaths";
8
+ import { CsrDevResolver } from "./csrDevResolver";
9
+ import type { CsrDevContext } from "./csrDevTypes";
10
+
11
+ const context: CsrDevContext = {
12
+ pageEntries: [],
13
+ basePaths: [],
14
+ htmlBasePaths: [],
15
+ define: {},
16
+ optimizeImports: [],
17
+ configKey: "key",
18
+ refreshFile: null,
19
+ };
20
+
21
+ //? esbuild's own interop helpers, as a pre-bundled chunk ships them (@mermaid-js/parser's dist/chunks).
22
+ const prebundled = `var __create = Object.create;
23
+ var __defProp = Object.defineProperty;
24
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
25
+ var __getOwnPropNames = Object.getOwnPropertyNames;
26
+ var __getProtoOf = Object.getPrototypeOf;
27
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
28
+ var __copyProps = (to, from, except, desc) => {
29
+ if (from && typeof from === "object" || typeof from === "function") {
30
+ for (let key of __getOwnPropNames(from))
31
+ if (!__hasOwnProp.call(to, key) && key !== except)
32
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
33
+ }
34
+ return to;
35
+ };
36
+ var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
37
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod));
38
+ var parts = {};
39
+ __reExport(parts, { a: 1 });
40
+ var wrapped = __toESM({ b: 2 });
41
+ export { parts, wrapped };
42
+ `;
43
+
44
+ describe("CsrDevModuleCompiler", () => {
45
+ let root: string;
46
+ const file = (relative: string) => path.join(root, relative);
47
+ const compile = async (relative: string) => {
48
+ const paths = new CsrDevPaths(root);
49
+ const compiler = new CsrDevModuleCompiler({
50
+ app: { verbose: () => undefined } as unknown as App,
51
+ paths,
52
+ resolver: new CsrDevResolver({ paths, context, entryFiles: [] }),
53
+ context,
54
+ outDir: file("out"),
55
+ routePrefix: "/_akan/registry/",
56
+ });
57
+ return await compiler.compile([file(relative)], new Set());
58
+ };
59
+
60
+ beforeAll(async () => {
61
+ root = CsrDevPaths.realpath(await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-compiler-")));
62
+ await Bun.write(file("node_modules/prebundled/chunk.mjs"), prebundled);
63
+ await Bun.write(file("node_modules/bare-reexport/index.mjs"), "export const parts = __reExport({}, { a: 1 });\n");
64
+ await Bun.write(file("node_modules/bare-toesm/index.mjs"), "export const wrapped = __toESM({ b: 2 });\n");
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await rm(root, { recursive: true, force: true });
69
+ });
70
+
71
+ test("a pre-bundled file keeps its own __reExport and __toESM, which Bun leaves in place of its own", async () => {
72
+ const { modules } = await compile("node_modules/prebundled/chunk.mjs");
73
+ const [module] = modules;
74
+ expect(module?.id).toBe("node_modules/prebundled/chunk.mjs");
75
+ const factory = module?.factory ?? "";
76
+ expect(factory).toContain("var __reExport = (target, mod, secondTarget) => (");
77
+ expect(factory).toContain("var __toESM = (mod, isNodeMode, target) => (");
78
+ expect(factory).not.toContain("__akan.reExport");
79
+ expect(factory).not.toContain("__akan.toESM");
80
+ expect(() => new Function(`return (${factory});`)).not.toThrow();
81
+ });
82
+
83
+ test.each([
84
+ ["__reExport", "node_modules/bare-reexport/index.mjs"],
85
+ ["__toESM", "node_modules/bare-toesm/index.mjs"],
86
+ ])("a %s call with no definition anywhere still stops the build", async (name, relative) => {
87
+ await expect(compile(relative)).rejects.toThrow(`Bun's ${name} helper no longer reads`);
88
+ });
89
+ });
@@ -214,7 +214,7 @@ export class CsrDevModuleCompiler {
214
214
  const target = this.#resolver.resolve(
215
215
  importer,
216
216
  args.path,
217
- args.kind === "require-call" ? "require" : "import",
217
+ args.kind === "require-call" || args.kind === "require-resolve" ? "require" : "import",
218
218
  );
219
219
  if (target === CsrDevResolver.inline) return undefined;
220
220
  if (target === null) {
@@ -262,11 +262,15 @@ export class CsrDevModuleCompiler {
262
262
  static #factory(id: string, code: string): { factory: string; helpers: CsrDevSharedHelpers | null } {
263
263
  const prefix = CsrDevPaths.modulePrefix;
264
264
  let patched = code.replaceAll(`import("${prefix}`, `__akanImport("${prefix}`).replace(/^\/\/# debugId=.*$/m, "");
265
+ //? A file esbuild already bundled (mermaid's parser chunks) defines its own helper under the same name, below Bun's
266
+ //? preamble, and Bun then emits none. Only a copy there is exempt: one in the preamble is Bun's own, changed.
267
+ const marker = patched.search(/^\/\/ /m);
268
+ const body = marker < 0 ? "" : patched.slice(marker);
265
269
  for (const [name, params, method] of CsrDevModuleCompiler.#helperPatches) {
266
270
  const definition = `var ${name} = ${params} => {`;
267
271
  if (patched.includes(definition))
268
272
  patched = patched.replace(definition, `var ${name} = __akan.${method}, __bun${name} = ${params} => {`);
269
- else if (patched.includes(`${name}(`))
273
+ else if (patched.includes(`${name}(`) && !new RegExp(`\\b(?:var|let|const|function)\\s+${name}\\b`).test(body))
270
274
  throw new Error(
271
275
  `[csr-dev] ${id}: Bun's ${name} helper no longer reads "${definition}"; CsrDevModuleCompiler must follow`,
272
276
  );
@@ -129,8 +129,10 @@ describe("CsrDevPatcher", () => {
129
129
  async writeEntries() {
130
130
  return { files: { "": this.#entry }, changed: [] };
131
131
  }
132
+ //? Roots beside the entries, as the SSR registry takes every client entry a route reaches.
133
+ extraRoots: string[] = [];
132
134
  protected async rootFiles(entryFiles: string[]) {
133
- return entryFiles;
135
+ return [...entryFiles, ...this.extraRoots];
134
136
  }
135
137
  override async metadataFingerprint() {
136
138
  return "same";
@@ -143,12 +145,13 @@ describe("CsrDevPatcher", () => {
143
145
  }
144
146
  }
145
147
 
146
- const movedRegistry = async (files: Record<string, string>) => {
148
+ const movedRegistry = async (files: Record<string, string>, { roots = [] }: { roots?: string[] } = {}) => {
147
149
  const root = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-move-"));
148
150
  dirs.push(root);
149
151
  const file = (relative: string) => path.join(CsrDevPaths.realpath(root), "apps/demo", relative);
150
152
  for (const [relative, text] of Object.entries(files)) await Bun.write(file(relative), text);
151
153
  const bundler = new MovedRegistry(root, file("entry.ts"));
154
+ bundler.extraRoots = roots.map((relative) => file(relative));
152
155
  expect(await bundler.update([])).toMatchObject({ generation: 1, reload: true, first: true });
153
156
  const patcher = new CsrDevPatcher(bundler, { resident: true });
154
157
  expect(await patcher.update([])).toEqual({ kind: "unchanged" });
@@ -286,6 +289,235 @@ describe("CsrDevPatcher", () => {
286
289
  expect(changedIds(await patcher.update([file("ui/Card.tsx")]))).toEqual(["apps/demo/ui/Card.tsx"]);
287
290
  });
288
291
 
292
+ test("a broken module in the graph whose last importer drops it leaves the graph instead of failing every save", async () => {
293
+ const { bundler, patcher, file, changedIds } = await movedRegistry({
294
+ "ui/X.tsx": "export const x = 1;\n",
295
+ "ui/C.tsx": 'export const C = () => "c1";\n',
296
+ "ui/I.tsx": 'import { x } from "./X";\nexport const I = () => x;\n',
297
+ "entry.ts": 'import { C } from "./ui/C";\nimport { I } from "./ui/I";\nexport const run = [C, I];\n',
298
+ });
299
+ await Bun.write(file("ui/X.tsx"), "export const x = ;\n");
300
+ await expect(patcher.update([file("ui/X.tsx")])).rejects.toThrow();
301
+ await Bun.write(file("ui/I.tsx"), 'import { x } from "./X";\nexport const I = () => x + 1;\n');
302
+ await expect(patcher.update([file("ui/I.tsx")])).rejects.toThrow();
303
+
304
+ await Bun.write(file("ui/I.tsx"), "export const I = () => 0;\n");
305
+ expect(changedIds(await patcher.update([file("ui/I.tsx")]))).toEqual(["apps/demo/ui/I.tsx"]);
306
+ expect(((await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph).pending).toEqual([]);
307
+ await Bun.write(file("ui/C.tsx"), 'export const C = () => "c2";\n');
308
+ expect(changedIds(await patcher.update([file("ui/C.tsx")]))).toEqual(["apps/demo/ui/C.tsx"]);
309
+ const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
310
+ expect(graph.modules["apps/demo/ui/X.tsx"]).toBeUndefined();
311
+ expect(graph.pending).toEqual([]);
312
+ });
313
+
314
+ test("a broken module left behind by a detached importer, or in a cycle, leaves with everything unreachable", async () => {
315
+ for (const shape of ["detached", "cycle"] as const) {
316
+ const { bundler, patcher, file, changedIds } = await movedRegistry({
317
+ "ui/X.tsx":
318
+ shape === "cycle" ? 'import { y } from "./Y";\nexport const x = () => y;\n' : "export const x = 1;\n",
319
+ "ui/Y.tsx": 'import { x } from "./X";\nexport const y = () => x;\n',
320
+ "ui/C.tsx": 'export const C = () => "c1";\n',
321
+ "ui/I.tsx": 'import { y } from "./Y";\nexport const I = () => y;\n',
322
+ "entry.ts": 'import { C } from "./ui/C";\nimport { I } from "./ui/I";\nexport const run = [C, I];\n',
323
+ });
324
+ await Bun.write(file("ui/X.tsx"), "export const x = ;\n");
325
+ await expect(patcher.update([file("ui/X.tsx")])).rejects.toThrow();
326
+ await Bun.write(file("ui/I.tsx"), "export const I = () => 0;\n");
327
+ expect(changedIds(await patcher.update([file("ui/I.tsx")]))).toEqual(["apps/demo/ui/I.tsx"]);
328
+ await Bun.write(file("ui/C.tsx"), 'export const C = () => "c2";\n');
329
+ expect(changedIds(await patcher.update([file("ui/C.tsx")]))).toEqual(["apps/demo/ui/C.tsx"]);
330
+ const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
331
+ expect(Object.keys(graph.modules).sort()).toEqual([
332
+ "apps/demo/entry.ts",
333
+ "apps/demo/ui/C.tsx",
334
+ "apps/demo/ui/I.tsx",
335
+ ]);
336
+ }
337
+ });
338
+
339
+ test("a broken client entry no page names any more leaves the registry, though its last importer was a server file", async () => {
340
+ const { bundler, patcher, file, changedIds } = await movedRegistry(
341
+ {
342
+ "ui/X.tsx": 'export const X = () => "x";\n',
343
+ "ui/Y.tsx": 'export const Y = () => "y1";\n',
344
+ "entry.ts": "export {};\n",
345
+ },
346
+ { roots: ["ui/X.tsx", "ui/Y.tsx"] },
347
+ );
348
+ await Bun.write(file("ui/X.tsx"), "export const X = () => ;\n");
349
+ await expect(patcher.update([file("ui/X.tsx")])).rejects.toThrow();
350
+ bundler.extraRoots = [file("ui/Y.tsx")];
351
+ await Bun.write(file("page/P.tsx"), "export default () => null;\n");
352
+ expect(await patcher.update([file("page/P.tsx")])).toEqual({ kind: "unchanged" });
353
+ const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
354
+ expect(graph.modules["apps/demo/ui/X.tsx"]).toBeUndefined();
355
+ expect(graph.pending).toEqual([]);
356
+ await Bun.write(file("ui/Y.tsx"), 'export const Y = () => "y2";\n');
357
+ expect(changedIds(await patcher.update([file("ui/Y.tsx")]))).toEqual(["apps/demo/ui/Y.tsx"]);
358
+ });
359
+
360
+ test("a require.resolve call weighs extensions as a require does", async () => {
361
+ const { bundler } = await movedRegistry({
362
+ "ui/pair/x.mjs": "export const x = 1;\n",
363
+ "ui/pair/x.js": "exports.x = 2;\n",
364
+ "ui/R.ts": 'export const where = require.resolve("./pair/x");\n',
365
+ "entry.ts": 'import { where } from "./ui/R";\nexport const run = where;\n',
366
+ });
367
+ const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
368
+ expect(graph.resolution["apps/demo/ui/R.ts"]?.["./pair/x"]).toBe("apps/demo/ui/pair/x.js");
369
+ });
370
+
371
+ test("two routes' checks that both fail keep both roots pending", async () => {
372
+ const { bundler, patcher, file } = await movedRegistry({
373
+ "ui/Card.tsx": 'export const card = () => "v1";\n',
374
+ "ui/E1.tsx": 'import { gone } from "./missing1";\nexport const e1 = () => gone;\n',
375
+ "ui/E4.tsx": 'import { gone } from "./missing4";\nexport const e4 = () => gone;\n',
376
+ "entry.ts": 'import { card } from "./ui/Card";\nexport const run = card;\n',
377
+ });
378
+ bundler.wanted = new Set([file("ui/E1.tsx"), file("ui/E4.tsx")]);
379
+ await expect(patcher.update([], { roots: [file("ui/E1.tsx")], onlyRoots: true })).rejects.toThrow();
380
+ await expect(patcher.update([], { roots: [file("ui/E4.tsx")], onlyRoots: true })).rejects.toThrow();
381
+ const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
382
+ expect(graph.pending.sort()).toEqual(["apps/demo/ui/E1.tsx", "apps/demo/ui/E4.tsx"]);
383
+ });
384
+
385
+ test("a route's check that adds its own root leaves another route's failed root pending", async () => {
386
+ const { bundler, patcher, file, changedIds } = await movedRegistry({
387
+ "ui/Card.tsx": 'export const card = () => "v1";\n',
388
+ "ui/E1.tsx": 'import { gone } from "./missingHelper";\nexport const e1 = () => gone;\n',
389
+ "ui/E3.tsx": "export const e3 = 3;\n",
390
+ "entry.ts": 'import { card } from "./ui/Card";\nexport const run = card;\n',
391
+ });
392
+ bundler.wanted = new Set([file("ui/E1.tsx"), file("ui/E3.tsx")]);
393
+ await expect(patcher.update([], { roots: [file("ui/E1.tsx")], onlyRoots: true })).rejects.toThrow();
394
+ await patcher.update([], { roots: [file("ui/E3.tsx")], onlyRoots: true });
395
+ const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
396
+ expect(graph.pending).toEqual(["apps/demo/ui/E1.tsx"]);
397
+
398
+ await Bun.write(file("ui/E1.tsx"), "export const e1 = () => 1;\n");
399
+ expect(changedIds(await patcher.update([file("ui/E1.tsx")]))).toEqual(["apps/demo/ui/E1.tsx"]);
400
+ });
401
+
402
+ test("a pending root a worker failed on waits for an edit of its own or of a file its failure named", async () => {
403
+ const { bundler, patcher, file, changedIds } = await movedRegistry({
404
+ "ui/Card.tsx": 'export const card = () => "v1";\n',
405
+ "ui/Other.tsx": 'export const other = () => "o1";\n',
406
+ "ui/Broken.tsx": 'import { gone } from "./missingHelper";\nexport const broken = () => gone;\n',
407
+ "entry.ts":
408
+ 'import { card } from "./ui/Card";\nimport { other } from "./ui/Other";\nexport const run = [card, other];\n',
409
+ "../../node_modules/fake-pkg/package.json": '{"name":"fake-pkg","main":"index.js"}',
410
+ "../../node_modules/fake-pkg/index.js": "export const gone = 1;\n",
411
+ });
412
+ bundler.wanted = new Set([file("ui/Broken.tsx")]);
413
+ await expect(patcher.update([], { roots: [file("ui/Broken.tsx")], onlyRoots: true })).rejects.toThrow();
414
+ const broken =
415
+ 'import { gone } from "fake-pkg";\nimport { nope } from "./Card";\nexport const broken = () => gone + nope;\n';
416
+ await Bun.write(file("ui/Broken.tsx"), broken);
417
+ expect((await patcher.update([file("ui/Broken.tsx")])).kind).toBe("delegate");
418
+ //? The worker the builder runs next fails in Broken, naming Card: the resident patcher reads the graph it wrote.
419
+ await expect(bundler.update([file("ui/Broken.tsx")])).rejects.toThrow();
420
+ patcher.forget();
421
+
422
+ await Bun.write(file("ui/Other.tsx"), 'export const other = () => "o2";\n');
423
+ expect(changedIds(await patcher.update([file("ui/Other.tsx")]))).toEqual(["apps/demo/ui/Other.tsx"]);
424
+ expect(await patcher.update([], { roots: [file("ui/Broken.tsx")], onlyRoots: true })).toEqual({
425
+ kind: "unchanged",
426
+ });
427
+
428
+ await Bun.write(file("ui/Card.tsx"), 'export const card = () => "v2";\nexport const nope = 1;\n');
429
+ expect((await patcher.update([file("ui/Card.tsx")])).kind).toBe("delegate");
430
+ //? Its worker has not run yet: a route's check waits for it rather than handing the root over again.
431
+ expect(await patcher.update([], { roots: [file("ui/Broken.tsx")], onlyRoots: true })).toEqual({
432
+ kind: "unchanged",
433
+ });
434
+ });
435
+
436
+ test("a root handed to a worker that died before writing its failure is handed over again", async () => {
437
+ const { bundler, patcher, file } = await movedRegistry({
438
+ "ui/Card.tsx": 'export const card = () => "v1";\n',
439
+ "entry.ts": 'import { card } from "./ui/Card";\nexport const run = card;\n',
440
+ "../../node_modules/fake-pkg/package.json": '{"name":"fake-pkg","main":"index.js"}',
441
+ "../../node_modules/fake-pkg/index.js": "export const gone = 1;\n",
442
+ });
443
+ await Bun.write(file("ui/B.tsx"), 'import { gone } from "fake-pkg";\nexport const b = () => gone;\n');
444
+ bundler.wanted = new Set([file("ui/B.tsx")]);
445
+ const check = async () => await patcher.update([], { roots: [file("ui/B.tsx")], onlyRoots: true });
446
+ expect((await check()).kind).toBe("delegate");
447
+ expect(await check()).toEqual({ kind: "unchanged" });
448
+ //? What the builder does when that worker is killed (an OOM) before it writes anything.
449
+ patcher.releaseHandedOver();
450
+ patcher.forget();
451
+ expect((await check()).kind).toBe("delegate");
452
+ });
453
+
454
+ test("a pending root a worker failed on because of another file is tried again once that file is fixed", async () => {
455
+ const { bundler, patcher, file } = await movedRegistry({
456
+ "ui/Card.tsx": 'export const card = () => "v1";\n',
457
+ "ui/C.tsx": 'export const c = () => "c1";\n',
458
+ "entry.ts": 'import { card } from "./ui/Card";\nimport { c } from "./ui/C";\nexport const run = [card, c];\n',
459
+ "../../node_modules/fake-pkg/package.json": '{"name":"fake-pkg","main":"index.js"}',
460
+ "../../node_modules/fake-pkg/index.js": "export const gone = 1;\n",
461
+ });
462
+ await Bun.write(file("ui/B.tsx"), 'import { gone } from "fake-pkg";\nexport const b = () => gone;\n');
463
+ bundler.wanted = new Set([file("ui/B.tsx")]);
464
+ await Bun.write(file("ui/C.tsx"), "export const c = () => ;\n");
465
+ await expect(patcher.update([file("ui/C.tsx")])).rejects.toThrow();
466
+ expect((await patcher.update([], { roots: [file("ui/B.tsx")], onlyRoots: true })).kind).toBe("delegate");
467
+ await expect(bundler.update([], { roots: [file("ui/B.tsx")] })).rejects.toThrow();
468
+ patcher.forget();
469
+
470
+ await Bun.write(file("ui/C.tsx"), 'export const c = () => "c2";\n');
471
+ expect((await patcher.update([file("ui/C.tsx")])).kind).toBe("delegate");
472
+ });
473
+
474
+ test.skipIf(!foldsCase)(
475
+ "an importer still naming the old spelling compiles with the new one, so app.js defines what it requires",
476
+ async () => {
477
+ const { patcher, file, app, changedIds } = await movedRegistry({
478
+ "ui/card.tsx": 'export const card = () => "v1";\n',
479
+ "ui/Page.tsx": 'import { card } from "./card";\nexport const page = () => card();\n',
480
+ "entry.ts": 'import { page } from "./ui/Page";\nexport const run = page;\n',
481
+ });
482
+ fs.renameSync(file("ui/card.tsx"), file("ui/Card.tsx"));
483
+ await Bun.write(file("ui/Card.tsx"), 'export const card = () => "v2";\n');
484
+ expect(changedIds(await patcher.update([file("ui/Card.tsx")]))).toEqual([
485
+ "apps/demo/ui/Card.tsx",
486
+ "apps/demo/ui/Page.tsx",
487
+ ]);
488
+ expect(await app()).toContain('__akan.define("apps/demo/ui/Card.tsx"');
489
+ expect(await app()).not.toContain('__akan.define("apps/demo/ui/card.tsx"');
490
+ await Bun.write(file("ui/Other.tsx"), "export const other = 1;\n");
491
+ expect(await patcher.update([file("ui/Other.tsx")])).toEqual({ kind: "unchanged" });
492
+ },
493
+ );
494
+
495
+ test.skipIf(!foldsCase)(
496
+ "a case-only rename the patcher never saw moves the module once, and later saves leave it alone",
497
+ async () => {
498
+ for (const resident of [false, true]) {
499
+ const { bundler, patcher, file, changedIds } = await movedRegistry({
500
+ "ui/card.tsx": 'export const card = () => "v1";\n',
501
+ "ui/Page.tsx": 'import { card } from "./card";\nexport const page = () => card();\n',
502
+ "entry.ts": 'import { page } from "./ui/Page";\nexport const run = page;\n',
503
+ });
504
+ fs.renameSync(file("ui/card.tsx"), file("ui/Card.tsx"));
505
+ await Bun.write(file("ui/Page.tsx"), 'import { card } from "./Card";\nexport const page = () => card();\n');
506
+ const later = resident ? patcher : new CsrDevPatcher(bundler, { resident: true });
507
+ expect(changedIds(await later.update([file("ui/Page.tsx")]))).toEqual([
508
+ "apps/demo/ui/Card.tsx",
509
+ "apps/demo/ui/Page.tsx",
510
+ ]);
511
+ await Bun.write(file("ui/Card.tsx"), 'export const card = () => "v2";\n');
512
+ expect(changedIds(await later.update([file("ui/Card.tsx")]))).toEqual(["apps/demo/ui/Card.tsx"]);
513
+ await Bun.write(file("ui/Other.tsx"), "export const other = 1;\n");
514
+ expect(await later.update([file("ui/Other.tsx")])).toEqual({ kind: "unchanged" });
515
+ const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
516
+ expect(graph.modules["apps/demo/ui/card.tsx"]).toBeUndefined();
517
+ }
518
+ },
519
+ );
520
+
289
521
  test("by default a pending root counts as reached only while an entry or a module imports it", async () => {
290
522
  const { bundler, patcher, file, changedIds } = await movedRegistry({
291
523
  "ui/Card.tsx": 'export const card = () => "v1";\n',