@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
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
|
|