@pylonsync/functions 0.3.293 → 0.3.295

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.
@@ -58,6 +58,14 @@ export interface BuildOutput {
58
58
  * from `getManifest` (in-process SSR path).
59
59
  */
60
60
  export declare function buildClientBundle(appDirRel?: string): Promise<BuildOutput>;
61
+ /**
62
+ * Compile `app/globals.css` through Tailwind v4 (`@tailwindcss/cli`)
63
+ * if both are present. Returns the relative output path (under
64
+ * outdir) when produced, else null. Skipped silently when the
65
+ * project hasn't opted in to Tailwind — we don't want every SSR
66
+ * project to need Tailwind installed.
67
+ */
68
+ export declare function buildTailwind(fs: any, path: any, cwd: string, outdir: string, appDirRel: string): Promise<string | null>;
61
69
  /**
62
70
  * Return the bundle manifest. If a fresh manifest exists on disk,
63
71
  * use it (caching parse output across requests). Otherwise build
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.293",
3
+ "version": "0.3.295",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -23,6 +23,7 @@ import * as os from "node:os";
23
23
 
24
24
  import {
25
25
  buildClientBundle,
26
+ buildTailwind,
26
27
  type PylonBundleManifest,
27
28
  } from "./ssr-client-bundler";
28
29
  import { nearestBoundaryComponent } from "./ssr-client-boundary";
@@ -160,6 +161,38 @@ describe("ssr-client-bundler (Phase 1.5e)", () => {
160
161
  expect(chunkHasReact).toBe(true);
161
162
  });
162
163
 
164
+ test("client runtime wires the nav-fallback guard (uncaught render error → full page load)", async () => {
165
+ // Regression: a page that renders React-19-hoisted <title>/<meta>/<link> in
166
+ // its own tree (use the `metadata` export instead) makes a client-side nav
167
+ // re-render throw in React's commit phase — the URL changes but the page
168
+ // can't swap (white screen). hydrateRoot must carry an onUncaughtError that
169
+ // falls back to a full page load of the in-flight destination so nav
170
+ // degrades gracefully. The distinctive console string is preserved through
171
+ // minification, so we assert the guard actually ships in the bundle.
172
+ tempDir = makeFixture(
173
+ { "page.tsx": PAGE_BODY("Home") },
174
+ { "layout.tsx": LAYOUT_BODY },
175
+ );
176
+ originalCwd = process.cwd();
177
+ process.chdir(tempDir);
178
+
179
+ const { outdir } = await buildClientBundle();
180
+ // The runtime (hydrateRoot + guard) lands in the shared chunk with multiple
181
+ // entries, or inlined in the entry with one — read every emitted .js.
182
+ const readJsRecursive = (dir: string): string[] =>
183
+ fs.readdirSync(dir, { withFileTypes: true }).flatMap((d) => {
184
+ const full = path.join(dir, d.name);
185
+ if (d.isDirectory()) return readJsRecursive(full);
186
+ return d.name.endsWith(".js") ? [fs.readFileSync(full, "utf8")] : [];
187
+ });
188
+ const bundled = readJsRecursive(outdir).join("\n");
189
+
190
+ // onUncaughtError is a React hydrateRoot option (property name preserved).
191
+ expect(bundled).toMatch(/onUncaughtError/);
192
+ // The fallback path: a full page load of the pending destination.
193
+ expect(bundled).toMatch(/falling back to a full page load/);
194
+ });
195
+
163
196
  test("manifest names every route, each with a non-empty imports list", async () => {
164
197
  tempDir = makeFixture(
165
198
  {
@@ -347,3 +380,53 @@ describe("client boundary is wired into the build", () => {
347
380
  expect(allShared).toContain("getDerivedStateFromError");
348
381
  });
349
382
  });
383
+
384
+ describe("Tailwind compile is concurrency-safe", () => {
385
+ // Regression: `pylon dev` warms the SSR bundle in one runner process while the
386
+ // first requests drive their own rebuild in another. Both compiled Tailwind to
387
+ // a SHARED temp file (`.styles.build.css`); whichever renamed it first won, and
388
+ // the other's `renameSync` ENOENT'd → the compile threw → the route shipped
389
+ // with no `css` → the page rendered UNSTYLED. Reproduced ~25% of cold boots
390
+ // under concurrent load. The fix gives each compile a process-unique temp path
391
+ // and publishes (rename) before pruning others. Drive several compiles at once
392
+ // against one outdir and prove they all succeed.
393
+ test("concurrent compiles against one outdir all produce the stylesheet", async () => {
394
+ const root = makeFixture(
395
+ { "page.tsx": PAGE_BODY("Home") },
396
+ { "layout.tsx": LAYOUT_BODY },
397
+ );
398
+ tempDir = root; // registers it for afterEach cleanup
399
+ // Opt into Tailwind: a real globals.css the CLI can compile. `@tailwindcss/cli`
400
+ // + `tailwindcss` resolve through the fixture's node_modules symlink (it points
401
+ // at examples/ssr-hello/node_modules, which declares both).
402
+ fs.writeFileSync(
403
+ path.join(root, "app", "globals.css"),
404
+ '@import "tailwindcss";\n@source "../app/**/*.{tsx,ts,jsx,js}";\n',
405
+ "utf8",
406
+ );
407
+ originalCwd = process.cwd();
408
+ process.chdir(root);
409
+
410
+ const outdir = path.join(root, ".pylon", "client-build");
411
+ fs.mkdirSync(outdir, { recursive: true });
412
+
413
+ // Old code: at least one of these rejects with the rename ENOENT.
414
+ const results = await Promise.all(
415
+ Array.from({ length: 4 }, () =>
416
+ buildTailwind(fs, path, root, outdir, "app"),
417
+ ),
418
+ );
419
+
420
+ for (const name of results) {
421
+ expect(name).toMatch(/^styles-[a-z0-9]+\.css$/);
422
+ expect(fs.existsSync(path.join(outdir, name as string))).toBe(true);
423
+ }
424
+ // Deterministic output → identical hash → one published asset, no leftover
425
+ // temp files stranded in the outdir.
426
+ expect(new Set(results).size).toBe(1);
427
+ const stranded = (fs.readdirSync(outdir) as string[]).filter((f) =>
428
+ f.startsWith(".styles.build."),
429
+ );
430
+ expect(stranded).toEqual([]);
431
+ }, 20_000);
432
+ });
@@ -212,6 +212,10 @@ import { createPylonBoundary } from "./client-boundary";
212
212
 
213
213
  const routeCache = Object.create(null);
214
214
  let activeRoot = null;
215
+ // Destination of an in-flight client navigation. Read by hydrateRoot's
216
+ // onUncaughtError so a re-render that throws mid-nav degrades to a full page
217
+ // load instead of a white screen. Null when no nav is in flight.
218
+ let pendingNav = null;
215
219
  let manifestPromise = null;
216
220
  const prefetchedChunks = new Set();
217
221
 
@@ -466,7 +470,30 @@ export function hydrate(component, Page, Layouts) {
466
470
  data.component,
467
471
  navEpoch,
468
472
  );
469
- activeRoot = hydrateRoot(document, tree);
473
+ activeRoot = hydrateRoot(document, tree, {
474
+ // Safety net for client navigation. If a nav re-render throws an uncaught
475
+ // error in React's commit phase, the URL has already changed but the page
476
+ // can't swap — a white/stale screen. The classic trigger is a page that
477
+ // renders hoisted <title>/<meta>/<link> in its own tree (use the
478
+ // \`metadata\` export instead); React 19 owns those head nodes on the client
479
+ // and reconciling them across routes can throw. Rather than strand the
480
+ // user, fall back to a full page load of the pending destination, which
481
+ // re-renders it cleanly from SSR. Non-navigation errors keep React's
482
+ // default reporting.
483
+ onUncaughtError(error) {
484
+ if (pendingNav) {
485
+ const dest = pendingNav;
486
+ pendingNav = null;
487
+ console.error(
488
+ "[pylon ssr] client navigation failed to render; falling back to a full page load:",
489
+ error,
490
+ );
491
+ window.location.href = dest;
492
+ return;
493
+ }
494
+ console.error(error);
495
+ },
496
+ });
470
497
  installNavHandlers();
471
498
  return;
472
499
  }
@@ -544,13 +571,21 @@ async function navigate(href, opts) {
544
571
  setNavParams(data);
545
572
  navEpoch++;
546
573
  currentPageProps = withClientProps(data);
574
+ const target = url.pathname + url.search;
547
575
  const tree = withBoundary(
548
576
  buildTree(route.Page, route.Layouts, currentPageProps),
549
577
  data.component,
550
578
  navEpoch,
551
579
  );
580
+ // Track the in-flight destination so hydrateRoot's onUncaughtError can fall
581
+ // back to a full page load if this re-render throws in React's commit phase
582
+ // (instead of leaving the URL changed but the page unswapped). Cleared on the
583
+ // next macrotask once the commit has settled with no error.
584
+ pendingNav = target;
552
585
  activeRoot.render(tree);
553
- const target = url.pathname + url.search;
586
+ setTimeout(() => {
587
+ if (pendingNav === target) pendingNav = null;
588
+ }, 0);
554
589
  if (opts && opts.replace) {
555
590
  history.replaceState({ component: data.component }, "", target);
556
591
  } else if (push) {
@@ -833,6 +868,13 @@ async function _doBuild(appDirRel: string): Promise<BuildOutput> {
833
868
  return _doBuildInner(fs, path, cwd, appDirRel);
834
869
  }
835
870
 
871
+ // Monotonic per-process counter for the Tailwind temp filename. `pylon dev`
872
+ // warms the SSR bundle in one runner process while incoming requests can drive
873
+ // their own rebuild in another, so two `buildTailwind` calls may run against the
874
+ // same outdir at once. Combined with the pid it gives every compile a unique
875
+ // temp path, so concurrent builds never rename each other's file away.
876
+ let _styleBuildSeq = 0;
877
+
836
878
  /**
837
879
  * Compile `app/globals.css` through Tailwind v4 (`@tailwindcss/cli`)
838
880
  * if both are present. Returns the relative output path (under
@@ -840,7 +882,10 @@ async function _doBuild(appDirRel: string): Promise<BuildOutput> {
840
882
  * project hasn't opted in to Tailwind — we don't want every SSR
841
883
  * project to need Tailwind installed.
842
884
  */
843
- async function buildTailwind(
885
+ // Exported for the concurrency regression test (ssr-client-bundler.test.ts):
886
+ // it drives several compiles at once against one outdir to prove they no longer
887
+ // race on a shared temp file.
888
+ export async function buildTailwind(
844
889
  fs: any,
845
890
  path: any,
846
891
  cwd: string,
@@ -880,7 +925,15 @@ async function buildTailwind(
880
925
  //
881
926
  // Spawn the CLI. Bun is already running; reuse it as the interpreter so the
882
927
  // user doesn't need node on PATH.
883
- const tmpPath = path.join(outdir, ".styles.build.css");
928
+ // PROCESS-UNIQUE temp file. A shared name (`.styles.build.css`) let a
929
+ // concurrent build rename the file out from under this one — its `renameSync`
930
+ // then ENOENT'd, the compile threw, the route shipped with no `css`, and the
931
+ // page rendered UNSTYLED. Worst under cold-boot traffic (a warm build racing
932
+ // the first requests), which is exactly when it must not happen.
933
+ const tmpPath = path.join(
934
+ outdir,
935
+ `.styles.build.${process.pid}.${_styleBuildSeq++}.css`,
936
+ );
884
937
  const proc = (Bun as any).spawn({
885
938
  cmd: [process.execPath, cliPath, "-i", globalsPath, "-o", tmpPath, "--minify"],
886
939
  cwd,
@@ -890,6 +943,11 @@ async function buildTailwind(
890
943
  const exitCode = await proc.exited;
891
944
  if (exitCode !== 0) {
892
945
  const err = await new Response(proc.stderr).text();
946
+ try {
947
+ fs.rmSync(tmpPath, { force: true });
948
+ } catch {
949
+ /* nothing to clean up */
950
+ }
893
951
  throw new Error(`tailwindcss build failed (exit ${exitCode}): ${err}`);
894
952
  }
895
953
 
@@ -907,19 +965,22 @@ async function buildTailwind(
907
965
  const stylesName = `styles-${hash.toString(36).padStart(8, "0")}.css`;
908
966
  const outPath = path.join(outdir, stylesName);
909
967
 
910
- // Drop previous styles-*.css so the outdir doesn't accumulate stale builds
911
- // (the filename now changes on every class change), then move the freshly
912
- // compiled file into place.
968
+ // Publish our freshly-compiled CSS FIRST (rename replaces atomically on
969
+ // POSIX, so the asset is never momentarily absent), THEN prune OTHER stale
970
+ // styles-*.css. The old order pruned before renaming, so a concurrent build
971
+ // could delete the file we were about to publish; pruning our own name could
972
+ // delete a concurrent winner's identical output. Excluding `stylesName` keeps
973
+ // both safe while still clearing builds from earlier class changes.
974
+ fs.renameSync(tmpPath, outPath);
913
975
  try {
914
976
  for (const f of fs.readdirSync(outdir) as string[]) {
915
- if (f.startsWith("styles-") && f.endsWith(".css")) {
977
+ if (f.startsWith("styles-") && f.endsWith(".css") && f !== stylesName) {
916
978
  fs.rmSync(path.join(outdir, f), { force: true });
917
979
  }
918
980
  }
919
981
  } catch {
920
982
  /* outdir may not be listable on the first build — ignore */
921
983
  }
922
- fs.renameSync(tmpPath, outPath);
923
984
  return stylesName;
924
985
  }
925
986