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

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.
Files changed (34) hide show
  1. package/DEV_RUNTIME_KNOBS.md +9 -6
  2. package/akanApp/akanApp.host.ts +79 -22
  3. package/akanApp/devHostPolicy.test.ts +56 -11
  4. package/akanApp/devHostPolicy.ts +10 -10
  5. package/csrE2e/csrE2eHarness.fixture.ts +55 -5
  6. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  7. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  8. package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
  9. package/frontendBuild/clientEntryDiscovery.ts +112 -70
  10. package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
  11. package/frontendBuild/csrDevArtifactWriter.ts +4 -0
  12. package/frontendBuild/csrDevBundler.ts +1 -1
  13. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  14. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  15. package/frontendBuild/csrDevModuleCompiler.test.ts +261 -0
  16. package/frontendBuild/csrDevModuleCompiler.ts +95 -20
  17. package/frontendBuild/csrDevPatcher.test.ts +234 -2
  18. package/frontendBuild/csrDevPatcher.ts +184 -10
  19. package/frontendBuild/csrDevPaths.ts +32 -11
  20. package/frontendBuild/csrDevResolver.test.ts +76 -2
  21. package/frontendBuild/csrDevResolver.ts +141 -12
  22. package/frontendBuild/csrDevTypes.ts +2 -0
  23. package/frontendBuild/devRegistryBundler.ts +18 -3
  24. package/frontendBuild/ssrDevBundler.ts +2 -8
  25. package/incrementalBuilder/incrementalBuilder.host.test.ts +34 -0
  26. package/incrementalBuilder/incrementalBuilder.host.ts +18 -3
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +62 -1
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +58 -24
  29. package/integration/devStability.integration.test.ts +9 -2
  30. package/mobile/nativeApp.test.ts +15 -0
  31. package/mobile/nativeApp.ts +54 -14
  32. package/mobile/nativeAppLine.test.ts +62 -0
  33. package/mobile/nativeAppLine.ts +63 -0
  34. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import type { BunPlugin } from "bun";
3
3
  import type { App } from "../commandDecorators";
4
+ import { CsrDevFactoryCheck } from "./csrDevFactoryCheck";
4
5
  import { CsrDevPaths } from "./csrDevPaths";
5
6
  import { CsrDevResolver } from "./csrDevResolver";
6
7
  import type { CsrDevCompiledModule, CsrDevContext, CsrDevSharedHelpers } from "./csrDevTypes";
@@ -82,6 +83,7 @@ export class CsrDevModuleCompiler {
82
83
  while (frontier.length > 0) {
83
84
  const refusedVendors = refuseNewVendor ? frontier.filter((file) => CsrDevPaths.isVendorFile(file)) : [];
84
85
  if (refusedVendors.length > 0) return { modules: [...compiled.values()], refusedVendors, unresolved: [] };
86
+ await this.#resolver.cover(frontier);
85
87
  const round = await this.#compileRound(frontier);
86
88
  if (round.misses.length > 0 && !this.#resolver.prepassDone && refusePrepass)
87
89
  return { modules: [...compiled.values()], refusedVendors: [], unresolved: round.misses };
@@ -138,21 +140,29 @@ export class CsrDevModuleCompiler {
138
140
  ),
139
141
  );
140
142
  const misses: string[] = [];
141
- const result = await Bun.build({
142
- entrypoints: files,
143
- root: this.#paths.root,
144
- target: "browser",
145
- format: "cjs",
146
- reactFastRefresh: !vendor,
147
- sourcemap: vendor ? "none" : "external",
148
- naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
149
- publicPath: this.#routePrefix,
150
- metafile: true,
151
- env: "AKAN_PUBLIC_*",
152
- define: this.#context.define,
153
- optimizeImports: this.#context.optimizeImports,
154
- plugins: [this.#registryPlugin(vendor, misses)],
155
- });
143
+ let result: Awaited<ReturnType<typeof Bun.build>>;
144
+ try {
145
+ result = await Bun.build({
146
+ entrypoints: files,
147
+ root: this.#paths.root,
148
+ target: "browser",
149
+ format: "cjs",
150
+ reactFastRefresh: !vendor,
151
+ sourcemap: vendor ? "none" : "external",
152
+ naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
153
+ publicPath: this.#routePrefix,
154
+ metafile: true,
155
+ env: "AKAN_PUBLIC_*",
156
+ //? A factory runs as a classic script, where `import.meta` is a SyntaxError that takes the whole vendor file
157
+ //? down, and Bun's cjs output keeps `import.meta.env` and spells `import.meta.url` as this disk's `file://` path.
158
+ define: { ...this.#context.define, "import.meta": "__akanMeta" },
159
+ optimizeImports: this.#context.optimizeImports,
160
+ plugins: [this.#registryPlugin(vendor, misses)],
161
+ });
162
+ } catch (error) {
163
+ if (vendor && error instanceof AggregateError) return await this.#withoutFailed(files, error);
164
+ throw error;
165
+ }
156
166
  const { outputs } = result.metafile as unknown as OutputMetafile;
157
167
  const sourceMaps = new Map(
158
168
  result.outputs.filter((artifact) => artifact.kind === "sourcemap").map((artifact) => [artifact.path, artifact]),
@@ -177,12 +187,20 @@ export class CsrDevModuleCompiler {
177
187
  }
178
188
  const code = await artifact.text();
179
189
  const id = this.#paths.idOf(file);
190
+ const built = CsrDevModuleCompiler.#factory(id, code);
191
+ const problem = CsrDevFactoryCheck.problemOf(built.factory, built.helpers?.definition);
192
+ if (problem && !vendor)
193
+ throw new Error(`[csr-dev] ${id}: its factory does not parse as a classic script: ${problem}`);
194
+ if (problem) {
195
+ modules.push(this.#leftOut(file, problem));
196
+ continue;
197
+ }
180
198
  const sourceMap = sourceMaps.get(`${artifact.path}.map`);
181
199
  modules.push({
182
200
  id,
183
201
  file,
184
202
  vendor,
185
- ...CsrDevModuleCompiler.#factory(id, code),
203
+ ...built,
186
204
  sourceMap: sourceMap ? this.#rebaseSourceMap(await sourceMap.text()) : undefined,
187
205
  deps: this.#emittedDeps(code),
188
206
  mtimeMs: mtimes.get(file) ?? CsrDevPaths.mtimeOf(file),
@@ -192,6 +210,50 @@ export class CsrDevModuleCompiler {
192
210
  return { modules, misses, tangled };
193
211
  }
194
212
 
213
+ //? A package file that cannot be a CommonJS factory would fail every module built beside it, and no save fixes a
214
+ //? package: it is left out and named in the log, and the module requiring it gets the reason. App code still fails.
215
+ async #withoutFailed(files: string[], error: AggregateError): Promise<BuildRound> {
216
+ const reasons = new Map<string, string[]>();
217
+ for (const message of error.errors as {
218
+ message?: unknown;
219
+ position?: { file?: unknown; line?: number } | null;
220
+ }[]) {
221
+ const file = message.position?.file;
222
+ if (typeof file !== "string" || !path.isAbsolute(file)) continue;
223
+ const real = CsrDevPaths.realpath(file);
224
+ const at = message.position?.line ? `:${message.position.line}` : "";
225
+ reasons.set(real, [...(reasons.get(real) ?? []), `${String(message.message)} (${this.#paths.idOf(real)}${at})`]);
226
+ }
227
+ const failed = new Set(files.filter((file) => reasons.has(CsrDevPaths.realpath(file))));
228
+ if (failed.size === 0) throw error;
229
+ const rest = files.filter((file) => !failed.has(file));
230
+ const round = rest.length > 0 ? await this.#bunBuild(rest, true) : { modules: [], misses: [], tangled: [] };
231
+ for (const file of failed) {
232
+ const text = (reasons.get(CsrDevPaths.realpath(file)) ?? []).join("; ");
233
+ const awaits = text.includes('"await" can only be used');
234
+ round.modules.push(
235
+ this.#leftOut(file, awaits ? `a top-level await, which a CommonJS factory cannot wait on: ${text}` : text),
236
+ );
237
+ }
238
+ return round;
239
+ }
240
+
241
+ #leftOut(file: string, problem: string): CsrDevCompiledModule {
242
+ const real = CsrDevPaths.realpath(file);
243
+ const id = this.#paths.idOf(real);
244
+ this.#app.logger.error(`[csr-dev] ${id} is left out of the dev registry: ${problem}`);
245
+ return {
246
+ id,
247
+ file: real,
248
+ vendor: true,
249
+ factory: CsrDevFactoryCheck.thrower(id, problem),
250
+ helpers: null,
251
+ deps: [],
252
+ mtimeMs: CsrDevPaths.mtimeOf(real),
253
+ hash: "",
254
+ };
255
+ }
256
+
195
257
  #registryPlugin(vendor: boolean, misses: string[]): BunPlugin {
196
258
  return {
197
259
  name: "akan-csr-registry",
@@ -214,7 +276,7 @@ export class CsrDevModuleCompiler {
214
276
  const target = this.#resolver.resolve(
215
277
  importer,
216
278
  args.path,
217
- args.kind === "require-call" ? "require" : "import",
279
+ args.kind === "require-call" || args.kind === "require-resolve" ? "require" : "import",
218
280
  );
219
281
  if (target === CsrDevResolver.inline) return undefined;
220
282
  if (target === null) {
@@ -261,12 +323,20 @@ export class CsrDevModuleCompiler {
261
323
  // Every rewrite keeps the line count, so a module's source map still lines up inside the factory.
262
324
  static #factory(id: string, code: string): { factory: string; helpers: CsrDevSharedHelpers | null } {
263
325
  const prefix = CsrDevPaths.modulePrefix;
264
- let patched = code.replaceAll(`import("${prefix}`, `__akanImport("${prefix}`).replace(/^\/\/# debugId=.*$/m, "");
326
+ //? Bun keeps a package's `#!` line atop its output, where a script inside a function cannot have one.
327
+ let patched = code
328
+ .replace(/^#!/, "//")
329
+ .replaceAll(`import("${prefix}`, `__akanImport("${prefix}`)
330
+ .replace(/^\/\/# debugId=.*$/m, "");
331
+ //? A file esbuild already bundled (mermaid's parser chunks) defines its own helper under the same name, below Bun's
332
+ //? preamble, and Bun then emits none. Only a copy there is exempt: one in the preamble is Bun's own, changed.
333
+ const marker = patched.search(/^\/\/ /m);
334
+ const body = marker < 0 ? "" : patched.slice(marker);
265
335
  for (const [name, params, method] of CsrDevModuleCompiler.#helperPatches) {
266
336
  const definition = `var ${name} = ${params} => {`;
267
337
  if (patched.includes(definition))
268
338
  patched = patched.replace(definition, `var ${name} = __akan.${method}, __bun${name} = ${params} => {`);
269
- else if (patched.includes(`${name}(`))
339
+ else if (patched.includes(`${name}(`) && !new RegExp(`\\b(?:var|let|const|function)\\s+${name}\\b`).test(body))
270
340
  throw new Error(
271
341
  `[csr-dev] ${id}: Bun's ${name} helper no longer reads "${definition}"; CsrDevModuleCompiler must follow`,
272
342
  );
@@ -275,8 +345,13 @@ export class CsrDevModuleCompiler {
275
345
  // re-running the store root is the whole update, and bubbling past it would only reach the page modules.
276
346
  if (CsrDevModuleCompiler.#storeRoot.test(id)) patched += "\nmodule.hot.accept();";
277
347
  const shared = CsrDevModuleCompiler.#shareHelpers(patched);
348
+ //? What `import.meta` is in a browser module: a `url` and no `env`, so `import.meta.env` checks (jotai, zustand)
349
+ //? branch as they do in the ESM bundle. On the header line, outside the preamble that modules share.
350
+ const meta = /(?<![\w$.])__akanMeta(?![\w$])/.test(patched)
351
+ ? ` var __akanMeta = { url: new URL(${JSON.stringify(id.startsWith("/") ? id : `/${id}`)}, self.location.href).href };`
352
+ : "";
278
353
  return {
279
- factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {\n${shared?.code ?? patched}\n}`,
354
+ factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {${meta}\n${shared?.code ?? patched}\n}`,
280
355
  helpers: shared?.helpers ?? null,
281
356
  };
282
357
  }
@@ -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',