@uniflowed/vite 0.0.0-alpha.14 → 0.0.0-alpha.16

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.
@@ -9,7 +9,10 @@
9
9
  // imports", `resolveId` + `load` + `generateBundle` + `writeBundle` +
10
10
  // `transformIndexHtml` — since before there was anything behind it, and
11
11
  // `uf inspect` has been listing it in the resolved pipeline. This is the
12
- // implementation of a plugin uf was already claiming to run.
12
+ // implementation of a plugin uf was already claiming to run — and `resolveId`
13
+ // and `generateBundle`, declared there from the start and unimplemented until
14
+ // icons arrived, are now real: the first resolves `uf:icon/…`, the second
15
+ // assembles the sprite once the graph is complete.
13
16
  //
14
17
  // # What an import becomes
15
18
  //
@@ -45,6 +48,19 @@
45
48
  // two must agree": there is one pipeline and one set of bytes, and the only
46
49
  // thing that differs between them is the URL prefix they are served under.
47
50
  //
51
+ // # Icons, and why they are not `.svg`
52
+ //
53
+ // `import Star from "uf:icon/star"` resolves against the directory
54
+ // `app.builtins.icons.dir` names, and `import sprite from "uf:icon-sprite"` is
55
+ // the sprite built from every icon the build reached. Neither claims an
56
+ // extension, which is the point: `.svg` stays Vite's, so `vite-plugin-svgr`
57
+ // and everything like it keep working, and uf adds a capability in its own
58
+ // namespace instead of taking one away.
59
+ //
60
+ // The sprite is emitted in `generateBundle`, which is the first hook that runs
61
+ // after every module has been loaded — and therefore the first moment the set
62
+ // of icons a build reached is the whole set.
63
+ //
48
64
  // # What this plugin deliberately does not claim
49
65
  //
50
66
  // An import with a query — `./hero.png?url`, `?raw`, `?inline` — is left to
@@ -57,7 +73,13 @@
57
73
  import { existsSync, readFileSync } from "node:fs";
58
74
  import path from "node:path";
59
75
 
60
- import { AssetService, assetKind } from "@uniflowed/host/assets";
76
+ import {
77
+ AssetService,
78
+ ICON_PREFIX,
79
+ ICON_SPRITE,
80
+ OG_EXTENSION,
81
+ assetKind,
82
+ } from "@uniflowed/host/assets";
61
83
 
62
84
  /** Where transformed assets are kept, relative to the project root. */
63
85
  export const CACHE_DIR = ".uf/cache/assets";
@@ -71,6 +93,36 @@ export const CACHE_DIR = ".uf/cache/assets";
71
93
  */
72
94
  export const DEV_PREFIX = "@uf-asset/";
73
95
 
96
+ /**
97
+ * What stands in for the sprite until `generateBundle` knows what is in it.
98
+ *
99
+ * A string no source file would contain, replaced in the generated chunk once
100
+ * every module has been loaded. The module graph needs *something* at `load`
101
+ * time and the sprite is not knowable then; see the `sprite` branch of `load`.
102
+ */
103
+ const SPRITE_PLACEHOLDER = "__UF_ICON_SPRITE__";
104
+
105
+ /**
106
+ * What a `*.og.json` import resolves to.
107
+ *
108
+ * A card cannot keep its own id, and the reason is worth writing down because
109
+ * nothing about `enforce: "pre"` prevents it. Vite's JSON handling is a
110
+ * *native* rolldown plugin, `builtin:vite-json`, and it selects modules by
111
+ * **id**: anything still ending in `.json` when its `transform` runs is put
112
+ * through a JSON parser, whatever an earlier `load` returned. Ordering the
113
+ * hooks does not help, because the module this plugin loads is JavaScript
114
+ * under a name that says JSON — and the parser's answer is
115
+ * `expected value at line 1 column 1`.
116
+ *
117
+ * So the id changes rather than the order. The card resolves to
118
+ * `\0uf-og:<absolute path>.js`: `\0` is the convention for a module that is
119
+ * not a file, and the trailing extension is what takes it out of every
120
+ * `.json` filter in the pipeline.
121
+ */
122
+ const OG_PREFIX = "\0uf-og:";
123
+ /** Appended to that id so it does not end in `.json`. See [`OG_PREFIX`]. */
124
+ const OG_SUFFIX = ".js";
125
+
74
126
  /**
75
127
  * The module source for one transformed asset.
76
128
  *
@@ -146,15 +198,20 @@ export function withUrls(image, baseUrl) {
146
198
  * @param {object} options
147
199
  * @param {object} [options.images] `app.builtins.images`
148
200
  * @param {object} [options.fonts] `app.builtins.fonts`
201
+ * @param {object} [options.icons] `app.builtins.icons`
202
+ * @param {object} [options.og] `app.builtins.og`
149
203
  * @param {string} [options.command] the `uf` binary to transform through
150
204
  */
151
- export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
205
+ export function assetPlugin({ images = {}, fonts = {}, icons = {}, og = {}, command } = {}) {
152
206
  // Both halves can be turned off independently, and a plugin that is off is
153
207
  // still in the array: `uf inspect` lists the resolved pipeline, and a
154
208
  // pipeline that changes shape when a feature is disabled is a pipeline whose
155
209
  // listing cannot be compared between two projects.
156
210
  const imagesOn = images.enabled !== false;
157
211
  const fontsOn = fonts.enabled !== false;
212
+ const iconsOn = icons.enabled !== false;
213
+ const ogOn = og.enabled !== false;
214
+ const iconDir = icons.dir ?? "icons";
158
215
 
159
216
  let root = process.cwd();
160
217
  let base = "/";
@@ -174,6 +231,18 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
174
231
  * redo every image, which is the whole cost this map exists to avoid.
175
232
  */
176
233
  const transformed = new Map();
234
+ /** Whether anything imported the sprite at all. */
235
+ let spriteRequested = false;
236
+ /**
237
+ * Every icon this build has reached, by symbol id.
238
+ *
239
+ * Here and not in the `uf assets` process, because this closure outlives it:
240
+ * a build closes the service in `buildEnd`, which runs *before*
241
+ * `generateBundle`, and a build with a client environment and a server one
242
+ * has two bundles and one set of icons between them. The plugin is what
243
+ * spans a build, so the plugin is what remembers.
244
+ */
245
+ const reachedIcons = new Map();
177
246
 
178
247
  const cacheDir = () => path.resolve(root, CACHE_DIR);
179
248
  /**
@@ -206,9 +275,32 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
206
275
  const kind = assetKind(id);
207
276
  if (kind === "image" && !imagesOn) return null;
208
277
  if (kind === "font" && !fontsOn) return null;
278
+ if (kind === "og" && !ogOn) return null;
279
+ if ((kind === "icon" || kind === "sprite") && !iconsOn) return null;
209
280
  return kind;
210
281
  };
211
282
 
283
+ /**
284
+ * The file one `uf:icon/<name>` names.
285
+ *
286
+ * The name is a path segment and nothing else. `..` in it would reach out of
287
+ * the icon directory and turn an import into a way to read the repository,
288
+ * so it is rejected rather than resolved — the same rule the dev middleware
289
+ * applies to an emitted file name.
290
+ */
291
+ const iconFile = (id) => {
292
+ const name = id.slice(ICON_PREFIX.length);
293
+ if (name === "" || name.includes("..") || path.isAbsolute(name)) return null;
294
+ return { name, file: path.resolve(root, iconDir, `${name}.svg`) };
295
+ };
296
+
297
+ /** Tell a dev server the sprite it already sent is missing an icon. */
298
+ const invalidateSprite = () => {
299
+ if (server == null || !spriteRequested) return;
300
+ const module = server.moduleGraph.getModuleById(ICON_SPRITE);
301
+ if (module != null) server.moduleGraph.invalidateModule(module);
302
+ };
303
+
212
304
  return {
213
305
  name: "uf:asset",
214
306
  // Before Vite's own asset handling, which would otherwise claim the same
@@ -222,9 +314,93 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
222
314
  isBuild = config.command === "build";
223
315
  },
224
316
 
317
+ // `uf:icon/…` and `uf:icon-sprite` are uf's own ids and resolve to
318
+ // themselves. Returning the id unchanged rather than a `\0`-prefixed one
319
+ // keeps it readable in a stack trace and in `vite --debug`, and nothing
320
+ // else in the pipeline claims the `uf:` scheme.
321
+ //
322
+ // A card is the opposite case: it has to *lose* its name, because the name
323
+ // is what the native JSON plugin claims it by. See `OG_PREFIX`.
324
+ async resolveId(id, importer, options) {
325
+ if (iconsOn && (id === ICON_SPRITE || id.startsWith(ICON_PREFIX))) return id;
326
+ if (!ogOn || !id.toLowerCase().endsWith(OG_EXTENSION)) return null;
327
+ // Compared against the id as written, so a query is never claimed:
328
+ // `./card.og.json?raw` and `?url` still reach the file, because a query
329
+ // is Vite's.
330
+ const resolved = await this.resolve(id, importer, { ...options, skipSelf: true });
331
+ if (resolved == null || resolved.external || !existsSync(resolved.id)) return resolved;
332
+ return `${OG_PREFIX}${resolved.id}${OG_SUFFIX}`;
333
+ },
334
+
225
335
  async load(id) {
336
+ if (id.startsWith(OG_PREFIX)) {
337
+ const file = id.slice(OG_PREFIX.length, -OG_SUFFIX.length);
338
+ // Watched by hand, because the module id is no longer the file's path
339
+ // and nothing else would associate the two. Without this a dev server
340
+ // never redraws a card whose template was edited.
341
+ this.addWatchFile(file);
342
+ return loadAsset.call(this, {
343
+ kind: "og",
344
+ file,
345
+ transformed,
346
+ service: ensureService(),
347
+ cacheDir: cacheDir(),
348
+ baseUrl: baseUrl(),
349
+ assetsDir,
350
+ isBuild,
351
+ images,
352
+ fonts,
353
+ });
354
+ }
355
+
226
356
  const kind = claims(id);
227
357
  if (kind == null) return null;
358
+
359
+ if (kind === "sprite") {
360
+ spriteRequested = true;
361
+ // A build cannot know the sprite here: `load` runs while the graph is
362
+ // still being walked, so the set of icons reached so far is not the
363
+ // set. It emits a placeholder that `generateBundle` — the first hook
364
+ // after every module has been loaded — replaces with the real markup.
365
+ //
366
+ // A dev server has no `generateBundle`, so it assembles from what has
367
+ // been reached and invalidates this module whenever a new icon turns
368
+ // up. That costs one extra reload the first time a page introduces an
369
+ // icon and is exactly right afterwards, which is the trade a dev
370
+ // server makes everywhere else too.
371
+ if (isBuild) return assetModuleSource({ markup: SPRITE_PLACEHOLDER });
372
+ const sprite = await ensureService().sprite({
373
+ outDir: cacheDir(),
374
+ icons: [...reachedIcons.values()],
375
+ });
376
+ return assetModuleSource({ markup: sprite.markup });
377
+ }
378
+
379
+ if (kind === "icon") {
380
+ const resolved = iconFile(id);
381
+ if (resolved == null) return null;
382
+ if (!existsSync(resolved.file)) {
383
+ this.error(
384
+ `${id} does not exist: uf looked for ${path.relative(root, resolved.file)}. ` +
385
+ "`app.builtins.icons.dir` is where `uf:icon/…` resolves against.",
386
+ );
387
+ }
388
+ const asset = await ensureService().icon(resolved.file, {
389
+ outDir: cacheDir(),
390
+ name: resolved.name,
391
+ });
392
+ this.addWatchFile(resolved.file);
393
+ reachedIcons.set(asset.id, asset);
394
+ invalidateSprite();
395
+ return assetModuleSource({
396
+ id: asset.id,
397
+ href: `#${asset.id}`,
398
+ viewBox: asset.viewBox,
399
+ width: asset.width,
400
+ height: asset.height,
401
+ });
402
+ }
403
+
228
404
  const file = path.resolve(id);
229
405
  // Not this plugin's to fail on: an id with one of these extensions that
230
406
  // is not a file on disk is a virtual module somebody else owns.
@@ -244,6 +420,38 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
244
420
  });
245
421
  },
246
422
 
423
+ // After every module has been loaded, which is the first moment the set of
424
+ // icons this build reached is the whole set. A sprite assembled in `load`
425
+ // would hold the icons reached *so far*, which is a different sprite on
426
+ // every run depending on module order.
427
+ async generateBundle(_options, bundle) {
428
+ if (!iconsOn || !spriteRequested) return;
429
+ const sprite = await ensureService().sprite({
430
+ outDir: cacheDir(),
431
+ icons: [...reachedIcons.values()],
432
+ });
433
+ // Inlined into the chunk and *not* emitted as a file of its own. An
434
+ // external sprite would be the better answer if it worked — one file
435
+ // cached across every page — but `<use href="sprite.svg#id">` does not
436
+ // resolve across documents in any version of Safari and is blocked
437
+ // cross-origin in Chrome, so the file would be dead weight in `dist/`
438
+ // and in `uf_bundle`'s size report. `uf assets` still writes it into the
439
+ // cache directory, which is where the dev server reads it from.
440
+ //
441
+ // The placeholder is replaced rather than the module re-run: by
442
+ // `generateBundle` the chunk is already generated, and the sprite is one
443
+ // string literal in it.
444
+ //
445
+ // A function replacement, because a plain string one would interpret
446
+ // `$&` and `$'` — and an icon is somebody else's markup.
447
+ const escaped = JSON.stringify(sprite.markup).slice(1, -1);
448
+ for (const chunk of Object.values(bundle)) {
449
+ if (chunk.type === "chunk" && chunk.code.includes(SPRITE_PLACEHOLDER)) {
450
+ chunk.code = chunk.code.replaceAll(SPRITE_PLACEHOLDER, () => escaped);
451
+ }
452
+ }
453
+ },
454
+
247
455
  configureServer(devServer) {
248
456
  server = devServer;
249
457
  devServer.httpServer?.once("close", () => {
@@ -288,6 +496,7 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
288
496
  // files and leaves the old ones for anything still holding a URL.
289
497
  transformed.delete(`image:${path.resolve(id)}`);
290
498
  transformed.delete(`font:${path.resolve(id)}`);
499
+ transformed.delete(`og:${path.resolve(id)}`);
291
500
  },
292
501
 
293
502
  buildEnd() {
@@ -319,24 +528,38 @@ async function loadAsset(context) {
319
528
  const key = `${kind}:${file}`;
320
529
  let manifest = transformed.get(key);
321
530
  if (manifest == null) {
322
- manifest =
323
- kind === "image"
324
- ? await service.image(file, {
325
- outDir: cacheDir,
326
- widths: images.widths,
327
- quality: images.quality,
328
- blur: images.placeholder,
329
- })
330
- : await service.font(file, {
331
- outDir: cacheDir,
332
- family: fonts.family,
333
- display: fonts.display,
334
- baseUrl,
335
- });
531
+ if (kind === "image") {
532
+ manifest = await service.image(file, {
533
+ outDir: cacheDir,
534
+ widths: images.widths,
535
+ quality: images.quality,
536
+ blur: images.placeholder,
537
+ });
538
+ } else if (kind === "og") {
539
+ manifest = await service.og(file, { outDir: cacheDir });
540
+ } else {
541
+ manifest = await service.font(file, {
542
+ outDir: cacheDir,
543
+ family: fonts.family,
544
+ display: fonts.display,
545
+ subset: fonts.subset,
546
+ preload: fonts.preload,
547
+ baseUrl,
548
+ });
549
+ }
336
550
  transformed.set(key, manifest);
337
551
  }
338
552
 
339
- const files = kind === "image" ? manifest.variants.map((v) => v.file) : [manifest.file];
553
+ const files =
554
+ kind === "image"
555
+ ? manifest.variants.map((v) => v.file)
556
+ : kind === "og"
557
+ ? [manifest.file]
558
+ : // Every bucket, not just the primary: a `unicode-range` split emits
559
+ // one file per script and the browser fetches whichever the page
560
+ // needs. Emitting only the one the manifest calls primary would
561
+ // leave the others named in the stylesheet and absent from `dist/`.
562
+ manifest.faces.map((face) => face.file);
340
563
  if (isBuild) {
341
564
  // Handed to Rollup rather than copied by hand, so the bundler owns what
342
565
  // lands in the output directory and `uf_bundle`'s size report — which
@@ -356,6 +579,18 @@ async function loadAsset(context) {
356
579
  if (kind === "image") {
357
580
  return assetModuleSource(withUrls(manifest, baseUrl));
358
581
  }
582
+ if (kind === "og") {
583
+ return assetModuleSource({
584
+ // The shape `Metadata.openGraph.images` and `OgImage` both read. A URL,
585
+ // a size and the alt text, which is all a card ever is to a page.
586
+ url: `${baseUrl}${manifest.file}`,
587
+ width: manifest.width,
588
+ height: manifest.height,
589
+ type: manifest.mime,
590
+ alt: manifest.alt,
591
+ bytes: manifest.bytes,
592
+ });
593
+ }
359
594
  return assetModuleSource({
360
595
  src: `${baseUrl}${manifest.file}`,
361
596
  family: manifest.family,
@@ -373,6 +608,19 @@ async function loadAsset(context) {
373
608
  metrics: manifest.metrics,
374
609
  fallback: manifest.fallback,
375
610
  fallbackDeclined: manifest.fallbackDeclined,
611
+ // Every emitted file with its URL, so `Font` can preload exactly the one
612
+ // marked rather than all of them — which is the one thing that would undo
613
+ // a `unicode-range` split.
614
+ faces: (manifest.faces ?? []).map((face) => ({
615
+ ...face,
616
+ url: `${baseUrl}${face.file}`,
617
+ })),
618
+ subset: manifest.subset ?? null,
619
+ // Carried through for the same reason `declined` is on an image: a project
620
+ // that asked for a subset and got the whole font is entitled to the
621
+ // sentence saying why, without reading this plugin.
622
+ subsetDeclined: manifest.subsetDeclined ?? null,
623
+ sourceBytes: manifest.sourceBytes ?? null,
376
624
  });
377
625
  }
378
626
 
@@ -0,0 +1,67 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // The embedded copy of `dist/`, handed to the bundler as a virtual module.
6
+ //
7
+ // `uf build --compile` puts everything `uf build` wrote inside the binary, and
8
+ // the way it travels there is one generated module: `uf_bundle::embed` writes
9
+ // `export const assets = JSON.parse("…")` into `.uf/build/compile/assets.js`,
10
+ // where the string is base64 of every file in the output directory. For the
11
+ // docs site that is eight megabytes; for a project with images it is tens.
12
+ //
13
+ // # Why it does not enter the graph as the file it is
14
+ //
15
+ // Because it is uf's own data, and the module graph is where uf's *source*
16
+ // transform is. `crates/uf_transform` refuses a source over
17
+ // `MAX_SOURCE_BYTES` — 8 MiB, a ceiling that bounds the parser against a file
18
+ // nobody meant to compile — and `.uf/build/compile/assets.js` is a file
19
+ // nobody wrote at all: its size is the size of the *build output*, which has
20
+ // no relationship to the size of any source file and grows every time a page
21
+ // is added. A site that crossed the line failed with
22
+ //
23
+ // TransformError: source is 8442536 bytes, over the 8388608 byte ceiling
24
+ // [plugin uf:flow] docs/.uf/build/compile/assets.js
25
+ //
26
+ // which names a file the project does not own, for a limit it did not break,
27
+ // at the end of a build that had already succeeded.
28
+ //
29
+ // So the payload enters the graph the way a build tool's own modules do,
30
+ // under a NUL-prefixed id. `@uniflowed/host/transform`'s `isFlowModule`
31
+ // declines those by name — "a build tool synthesises modules of its own", the
32
+ // first sentence of its contract — so the bytes go from disk to the bundler
33
+ // without being parsed as Flow on the way. Raising the ceiling instead would
34
+ // have weakened the one thing it is for, on every real source file, to make
35
+ // room for a file that is not source.
36
+ //
37
+ // The file on disk is still written and still what is served, so a person
38
+ // debugging a compiled binary can open the thing that went into it. What
39
+ // changed is only which name the bundler reaches it by.
40
+
41
+ import { readFileSync } from "node:fs";
42
+
43
+ /** What the generated entry imports. */
44
+ export const COMPILE_ASSETS_ID = "virtual:uf/compile-assets";
45
+
46
+ /** The resolved id Vite hands back for it. */
47
+ export const COMPILE_ASSETS_RESOLVED_ID = `\0${COMPILE_ASSETS_ID}`;
48
+
49
+ /**
50
+ * Serve `file` — `uf_bundle::embed`'s output — as [`COMPILE_ASSETS_ID`].
51
+ *
52
+ * Read at `load` rather than when the plugin is made, so a build that fails
53
+ * before it reaches the entry never pays for eight megabytes of string, and
54
+ * so the bytes are the ones on disk at the moment the bundler asked for them
55
+ * rather than at the moment the plugin list was assembled.
56
+ */
57
+ export function compileAssetsPlugin(file) {
58
+ return {
59
+ name: "uf:compile-assets",
60
+ resolveId(id) {
61
+ return id === COMPILE_ASSETS_ID ? COMPILE_ASSETS_RESOLVED_ID : null;
62
+ },
63
+ load(id) {
64
+ return id === COMPILE_ASSETS_RESOLVED_ID ? readFileSync(file, "utf8") : null;
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,117 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // React DevTools, in `uf dev`, on purpose.
6
+ //
7
+ // DevTools does not attach to React. React attaches to *DevTools*: while
8
+ // `react-dom` is being evaluated it looks for `__REACT_DEVTOOLS_GLOBAL_HOOK__`
9
+ // on the global object and registers itself with whatever it finds, once. A
10
+ // hook that arrives after that line has run is a hook no renderer ever sees, so
11
+ // everything below is about one ordering — the hook exists first — and about
12
+ // saying so in a file whose name a person can grep for.
13
+ //
14
+ // # Why this file exists at all, when it worked before it
15
+ //
16
+ // It did work, and by accident. The Fast Refresh preamble calls
17
+ // `injectIntoGlobalHook` (`./refresh-runtime.js`, Meta's runtime as vendored by
18
+ // `@vitejs/plugin-react`), and that function installs a hook when it finds
19
+ // none, because Fast Refresh needs one to decorate. So `uf dev` had a DevTools
20
+ // hook as a side effect of a function whose subject is hot reloading, in a file
21
+ // uf does not own, with nothing anywhere naming DevTools and no test that would
22
+ // notice its absence. The next upgrade of that vendored runtime, or a change to
23
+ // how the preamble is injected, could have taken it away in a diff nobody would
24
+ // read as being about DevTools. See ubugeeei-prod/uf#503.
25
+ //
26
+ // So the hook is installed here, first, deliberately, and `tests/library/
27
+ // devtools.test.js` runs this script's own text against a fake window.
28
+ //
29
+ // # Three things DevTools needs, and what carries each
30
+ //
31
+ // Naming them together, because each is provided somewhere else and each is one
32
+ // edit away from being lost:
33
+ //
34
+ // 1. **The hook, before the renderer.** This module, injected by
35
+ // `packages/vite/index.js`'s `transformIndexHtml` as a *classic* script at
36
+ // the top of the head — see [`devtoolsPreamble`] for why classic.
37
+ // 2. **One copy of the renderer.** `resolve.dedupe: ["react", "react-dom"]`
38
+ // in that same file. Two copies of `react-dom` register two renderers, and
39
+ // DevTools shows the tree of whichever one it heard from — which is the
40
+ // shape of the "multiple renderers concurrently rendering" report.
41
+ // 3. **The development build of it.** `mode` is `development`, so Vite
42
+ // resolves React's development export condition, and `uf transform` is
43
+ // called with `development: true` — which is what emits `jsxDEV` and the
44
+ // `_jsxFileName` beside every element. Against a production build DevTools
45
+ // says so and shows a tree with no props, no hooks and no source.
46
+ //
47
+ // # And out of a production build
48
+ //
49
+ // A production build injects none of this: `transformIndexHtml` returns an
50
+ // empty list unless the plugin is serving. That is the half a person can check
51
+ // on the artefact rather than by reading, and
52
+ // `crates/uf_cli/tests/vite.rs`'s `a_build_ships_no_devtools_hook` does.
53
+ //
54
+ // What it checks for is the *assignment* below rather than the name, and the
55
+ // distinction is worth stating here because the obvious test is wrong: React's
56
+ // own production build mentions `__REACT_DEVTOOLS_GLOBAL_HOOK__` twice, because
57
+ // reading that global is how a deployed React application is attachable at all.
58
+ // React reads it; only an installer writes it. So `window.<hook> =` is what
59
+ // must be absent, and it is absent because this function is never called
60
+ // outside a dev server.
61
+
62
+ /**
63
+ * The global React registers itself with.
64
+ *
65
+ * Written once, here, so that every other mention of it in uf — the injected
66
+ * script below, the assertion that a build has none — is this constant rather
67
+ * than a fourth spelling of a name whose whole value is that it matches
68
+ * React's exactly.
69
+ */
70
+ export const DEVTOOLS_HOOK = "__REACT_DEVTOOLS_GLOBAL_HOOK__";
71
+
72
+ /**
73
+ * The script every document loads before anything else in development.
74
+ *
75
+ * # A classic script, not a module
76
+ *
77
+ * Everything else uf injects is `type="module"`, and a module script is
78
+ * deferred: it runs after the document has been parsed, in document order with
79
+ * the other modules. That would still be early enough today, because the client
80
+ * entry is also a module and comes later — but "early enough as long as nobody
81
+ * adds a script above it" is exactly the accident this file exists to end. A
82
+ * classic inline script runs while the parser is on it, so no module, no
83
+ * import, and no `<script src>` a project's own Vite plugin injects can get
84
+ * between this and the renderer.
85
+ *
86
+ * # It never replaces a hook that is already there
87
+ *
88
+ * The DevTools extension installs its hook at `document_start`, which is before
89
+ * any script in the document, so on a machine that has DevTools the branch
90
+ * below is not taken and the extension's hook is what React registers with.
91
+ * Overwriting it would be the one way this file could break the thing it exists
92
+ * to support: the extension holds the connection to the panel, and a stub in
93
+ * its place is a page DevTools can see and never hear from.
94
+ *
95
+ * What is installed when there is nothing to leave alone is the minimum a
96
+ * renderer will register with — `renderers`, `supportsFiber`, `inject` and the
97
+ * three commit callbacks. It reports nothing to anybody: there is no panel, and
98
+ * the point of installing it is that `react-dom` takes the branch where a hook
99
+ * exists, so Fast Refresh has one to decorate and DevTools opened *later* in
100
+ * the same page finds a renderer already registered rather than a page that has
101
+ * to be reloaded. It is the same shape `injectIntoGlobalHook` installs, because
102
+ * it is the same contract; the difference is that this is uf saying so.
103
+ */
104
+ export function devtoolsPreamble() {
105
+ return `(function () {
106
+ if (window.${DEVTOOLS_HOOK} != null) return;
107
+ var nextID = 0;
108
+ window.${DEVTOOLS_HOOK} = {
109
+ renderers: new Map(),
110
+ supportsFiber: true,
111
+ inject: function () { return nextID++; },
112
+ onScheduleFiberRoot: function () {},
113
+ onCommitFiberRoot: function () {},
114
+ onCommitFiberUnmount: function () {},
115
+ };
116
+ })();`;
117
+ }
@@ -16,8 +16,11 @@
16
16
  //
17
17
  // * `POST /__uf/diagnostic` — a diagnostic a browser-side runtime produced
18
18
  // and wants a person to read. `@uniflowed/router`'s `internal/diagnostics.js`
19
- // is the client half, and the hydration-mismatch report beside it is what
20
- // calls it.
19
+ // is the client half; the hydration-mismatch report beside it is what calls
20
+ // it, and so is `internal/devtools.js`, which reads back after hydration
21
+ // whether React DevTools can attach to this page at all
22
+ // (ubugeeei-prod/uf#503). One channel and not one per feature is the whole
23
+ // point: a second endpoint would be a second thing to notice.
21
24
  // * `POST /__uf/vitals` — the five numbers `@uniflowed/web/vitals`
22
25
  // measures, posted by `vitalsBeacon()`. In production a project points the
23
26
  // beacon at an endpoint of its own; in development there was nothing at
@@ -790,12 +790,28 @@ export default routes;
790
790
  * The current route's modules are loaded *before* hydration so the first
791
791
  * render is synchronous and matches the server's HTML; a lazy import during
792
792
  * hydration would suspend and React would fall back to a client render.
793
+ *
794
+ * # Strict Mode is a generated constant, not a runtime check
795
+ *
796
+ * `strictMode` is written into this module as a literal, so a production build
797
+ * gets `hydrate({ … })` with the argument absent and Rollup has nothing to
798
+ * decide. It would have been shorter to have `hydrate` read `import.meta.hot`
799
+ * — the way `client.js` gates the hydration reporter — and that would have been
800
+ * one signal answering two questions: `uf.config.js` can turn Strict Mode off
801
+ * (ubugeeei-prod/uf#516) and `import.meta.hot` cannot be told about it. A
802
+ * project that sets `app.react.strictMode: false` gets a dev server that
803
+ * hydrates the way its deployment does, which is the whole of the escape
804
+ * hatch.
805
+ *
806
+ * @param {string} appEntry the project's `app.js`, as an import specifier
807
+ * @param {{ strictMode?: boolean }} [options]
793
808
  */
794
- export function clientModuleSource(appEntry) {
809
+ export function clientModuleSource(appEntry, options = {}) {
810
+ const strictMode = options.strictMode === true ? ", strictMode: true" : "";
795
811
  return `import { hydrate } from "@uniflowed/router/client";
796
812
  import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
797
813
  import App from ${JSON.stringify(appEntry)};
798
- hydrate({ App, routes, notFound, errors });
814
+ hydrate({ App, routes, notFound, errors${strictMode} });
799
815
  `;
800
816
  }
801
817
 
package/internal/serve.js CHANGED
@@ -362,6 +362,36 @@ export function createServeHandler({ entry, assets, distDir, cache }) {
362
362
  };
363
363
  }
364
364
 
365
+ /**
366
+ * Whether a file server may answer this request, or uf has to go first.
367
+ *
368
+ * `@uniflowed/server`'s `prerenderedMayAnswer`, reached the same way
369
+ * `createStaticHandler` is. It exists out here because of the one request
370
+ * `uf preview` may not leave to Vite: `createServeHandler` above is mounted
371
+ * *behind* Vite's static middleware, which is fine for everything except a
372
+ * request carrying the draft cookie. `createStaticHandler` declines a
373
+ * prerendered document for such a request and, under `uf preview`, never sees
374
+ * it — so draft mode appeared to be off there while it worked under `uf dev`
375
+ * and `uf start`. That is ubugeeei-prod/uf#620.
376
+ *
377
+ * `driver.js` asks this in a middleware mounted in *front* of Vite's, and
378
+ * mounts `createServeHandler` behind it for the answer, so a draft request
379
+ * goes through the same handler `uf start` uses and gets the same answer —
380
+ * including its stylesheets and chunks, which that handler still serves off
381
+ * disk. Every other request is untouched and Vite's file middleware runs as
382
+ * before.
383
+ *
384
+ * A gate rather than a second handler, because the caller has a request
385
+ * lifecycle to open and must not open one for a request it is about to hand
386
+ * on.
387
+ */
388
+ export function createPrerenderGate() {
389
+ const ready = deployment().then(({ prerenderedMayAnswer }) => prerenderedMayAnswer);
390
+ return async function prerenderedMayAnswer(cookieHeader) {
391
+ return (await ready)(cookieHeader ?? null);
392
+ };
393
+ }
394
+
365
395
  /**
366
396
  * A `Request`/`Response` handler as a Node request listener.
367
397
  *