@pitlane/dev 0.1.1 → 0.3.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # @pitlane/dev
2
2
 
3
+ ## 0.3.0
4
+
5
+ Dev-time hot module replacement.
6
+
7
+ - Component HMR: component and `clientEntry()` exports hot-swap in place
8
+ during `vite dev`, preserving live island state, via the `remix/ui-hmr`
9
+ browser and server transforms. Both named-function and arrow forms work —
10
+ arrow-form component/`clientEntry()` exports are normalized to named
11
+ function expressions before instrumentation, so idiomatic Remix code
12
+ hot-swaps with no source changes.
13
+ - Server-data HMR: editing a server-only module re-fetches the current page
14
+ through the app's fetch handler and reconciles the new server-rendered HTML
15
+ into the DOM, keeping hydrated island state — the Remix 3 analog of React
16
+ Router's loader/action revalidation, driven through the frame runtime. A
17
+ changed file the client graph serves as a script is left to component HMR
18
+ instead. Only `js` client modules count as client-served: plugins that scan
19
+ sources for their own purposes register non-script nodes for ordinary server
20
+ files (Tailwind's content scanner, for one), which previously classified
21
+ every server module as client-owned and silenced server-data HMR for the
22
+ whole app.
23
+ - Revalidating is one line in the document, `<HMR />` from the new `pitlane:dev`
24
+ module. It is a hydrated island, so it holds a component handle and
25
+ revalidates with `handle.frames.top.reload()`; `remix/ui` hands the top frame
26
+ to components only, so nothing the plugin injects could reach it. A frame
27
+ reload produces no history entry and fires no `navigate` event, which leaves
28
+ apps that intercept navigation themselves working unchanged. Needs no
29
+ environment guard: in a build, and in apps with no client runtime to hydrate
30
+ it, the specifier resolves to a component that renders nothing and carries no
31
+ client code. Types come with `@pitlane/dev/assets`.
32
+ - Revalidation waits a beat after a server change before refetching, so it
33
+ cannot reach the fetch handler while the server entry is still half-applied
34
+ (which served a dev error page on slower runtimes like workerd), and a burst
35
+ of saves coalesces into one refetch.
36
+ - `@pitlane/dev/runtime` imports now inline this package's real runtime module
37
+ rather than a hand-written copy of `mergeAssets`, so every export stays in
38
+ one place. What that module imports is bundled too, which keeps the built
39
+ server free of any dev-dependency import.
40
+
41
+ ## 0.2.0
42
+
43
+ Target Remix `3.0.0-beta.10`.
44
+
45
+ - Raised the `remix` peer dependency to `^3.0.0-beta.10` (from
46
+ `^3.0.0-beta.5`). Remix beta.6 removed the legacy package-aligned `remix/*`
47
+ import aliases, so beta.5 and earlier are no longer supported.
48
+ - `run()` from `remix/ui` now ships a default frame resolver and takes
49
+ `(src, options)`, so the documented `app/entry.browser.ts` no longer needs a
50
+ hand-written `resolveFrame`. The plugin itself is unchanged.
51
+ - Tested against Vite 8.1 (Rolldown), Vite+ 0.2 (`vp`), and
52
+ `remix@3.0.0-beta.10`.
53
+
3
54
  ## 0.1.1
4
55
 
5
56
  No changes to the plugin. First release published through the tokenless
package/README.md CHANGED
@@ -12,7 +12,7 @@ npm install --save-dev @pitlane/dev
12
12
  vp add -D @pitlane/dev
13
13
  ```
14
14
 
15
- Requires `remix@^3.0.0-beta.5` and `vite@>=7` as peers. v0.1.0 is tested against **Vite 8.1** (Rolldown), **Vite+ 0.2** (`vp`), and `remix@3.0.0-beta.5` — the [templates](https://github.com/pitlane-tools/templates) are the continuously tested reference.
15
+ Requires `remix@^3.0.0-beta.10` and `vite@>=7` as peers. Tested against **Vite 8.1** (Rolldown), **Vite+ 0.2** (`vp`), and `remix@3.0.0-beta.10` — the [templates](https://github.com/pitlane-tools/templates) are the continuously tested reference.
16
16
 
17
17
  ## Quick start
18
18
 
@@ -65,17 +65,50 @@ run({
65
65
  let mod = await import(/* @vite-ignore */ moduleUrl);
66
66
  return mod[exportName];
67
67
  },
68
- async resolveFrame(src, signal) {
69
- let response = await fetch(src, { headers: { accept: "text/html" }, signal });
70
- return response.body ?? (await response.text());
71
- },
72
68
  });
73
69
  ```
74
70
 
75
71
  `vite dev` serves the app through your router. `vite build` produces `dist/ssr` and `dist/client`. `vite preview` serves the production build through the same fetch handler production runs.
76
72
 
77
- > [!NOTE]
78
- > Dev updates are coarse-grained today: the server entry is re-imported per request, and client edits trigger a page refresh. Remix UI's first-class HMR runtime is in progress upstream ([remix-run/remix#11515](https://github.com/remix-run/remix/pull/11515)) — we're tracking it, and `@pitlane/dev` will ship the companion transform once it lands so components hot-swap in place.
73
+ ## Hot module replacement
74
+
75
+ `vite dev` hot-updates both halves of a Remix app in place, keeping live client state. Full details, including which edits preserve state and which remount, are in the [HMR guide](https://pitlane.tools/guides/hmr).
76
+
77
+ **Components.** Editing a component swaps its new code in without remounting, so hydrated `clientEntry()` islands keep their state (open menus, form input, counters). This runs the [`remix/ui-hmr`](https://github.com/remix-run/remix/tree/main/packages/ui-hmr) transforms during dev. Both authoring styles hot-swap, because `@pitlane/dev` normalizes arrow-form component and `clientEntry()` exports to named functions before instrumenting them:
78
+
79
+ ```tsx
80
+ // All of these hot-swap in place, preserving live state:
81
+ export const Counter = clientEntry(import.meta.url, handle => {
82
+ /* ... */
83
+ });
84
+ export const Toggle = clientEntry(import.meta.url, function Toggle(handle) {
85
+ /* ... */
86
+ });
87
+ export const Card = handle => () => <div />;
88
+ export function Panel(handle) {
89
+ /* ... */
90
+ }
91
+ ```
92
+
93
+ Only named (PascalCase) component exports in `.tsx`/`.jsx` files whose setup returns a render function are instrumented; other exports are left untouched. Editing the render function keeps live state. Editing the setup scope above the `return` remounts the component, so its state resets.
94
+
95
+ **Server data.** Editing a server-only module (the document, a middleware, a route handler, any module the client never imports) re-fetches the current page through your fetch handler and reconciles the new server-rendered HTML into the DOM. Hydrated island state survives, so you see fresh server output without a full-page reload. This is the Remix 3 analog of React Router's loader/action revalidation, driven through the frame runtime rather than a client data router.
96
+
97
+ It needs one line in your document:
98
+
99
+ ```tsx
100
+ import { HMR } from "pitlane:dev";
101
+
102
+ // ...
103
+ <body>
104
+ <HMR />
105
+ {/* ... */}
106
+ </body>;
107
+ ```
108
+
109
+ `<HMR />` is a hydrated island, so it has a component handle, and it revalidates with `handle.frames.top.reload()`. Remix hands the top frame to components only, which is why this is a component rather than something the plugin injects. Reloading the frame produces no history entry and fires no `navigate` event, so apps that intercept navigation themselves work unchanged.
110
+
111
+ Leave it unguarded: in a production build the specifier resolves to a component that renders nothing and carries no client code. Apps with `clientEntry: false` have nothing to hydrate it, so it stays inert there too. See the [HMR guide](https://pitlane.tools/guides/hmr).
79
112
 
80
113
  ## Options
81
114
 
@@ -88,11 +121,11 @@ remix({
88
121
  });
89
122
  ```
90
123
 
91
- | Option | Type | Default | Purpose |
92
- | -------------------- | ----------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
93
- | `clientEntry` | `string \| false` | `"app/entry.browser"` | Client entry module. Pass `false` for fully server-rendered apps with no hydration. |
94
- | `serverEntry` | `string` | `"app/entry.server"` | Server entry module, built as `dist/ssr/index.js`. |
95
- | `serverEnvironments` | `string[]` | `["ssr"]` | Environment names the `clientEntry()` transform treats as "server". |
124
+ | Option | Type | Default | Purpose |
125
+ | -------------------- | ----------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | `clientEntry` | `string \| false` | `"app/entry.browser"` | Client entry module. Pass `false` for fully server-rendered apps with no hydration. |
127
+ | `serverEntry` | `string` | `"app/entry.server"` | Server entry module, built as `dist/ssr/index.js`. |
128
+ | `serverEnvironments` | `string[]` | `["ssr"]` | Environment names the `clientEntry()` transform treats as "server". |
96
129
  | `serverHandler` | `boolean` | `true` | Serve dev requests through your server entry. Set `false` when `@cloudflare/vite-plugin`, `@netlify/vite-plugin`, or `nitro/vite` owns dev-time request handling. |
97
130
 
98
131
  ## The server entry contract
@@ -169,7 +202,14 @@ import { clientEntry, on } from "remix/ui";
169
202
  export const Counter = clientEntry(import.meta.url, handle => {
170
203
  let count = 0;
171
204
  return () => (
172
- <button mix={[on("click", () => { count++; handle.update(); })]}>
205
+ <button
206
+ mix={[
207
+ on("click", () => {
208
+ count++;
209
+ handle.update();
210
+ }),
211
+ ]}
212
+ >
173
213
  Count: <span>{count}</span>
174
214
  </button>
175
215
  );
@@ -282,12 +322,12 @@ dist/
282
322
 
283
323
  ## Compatibility
284
324
 
285
- | Dependency | Tested against |
286
- | ---------- | -------------- |
287
- | `vite` | 8.1.5 |
288
- | `vite-plus`| 0.2.6 |
289
- | `remix` | 3.0.0-beta.5 |
290
- | Node | 24 LTS, 25 |
325
+ | Dependency | Tested against |
326
+ | ----------- | -------------- |
327
+ | `vite` | 8.1.5 |
328
+ | `vite-plus` | 0.2.6 |
329
+ | `remix` | 3.0.0-beta.10 |
330
+ | Node | 24 LTS, 25 |
291
331
 
292
332
  Remix 3 is in beta; each `@pitlane/dev` release records the exact beta it was verified against. Rolldown is not required — the transform runs identically on generic Vite and Vite+.
293
333
 
@@ -299,7 +339,7 @@ Remix 3 is in beta; each `@pitlane/dev` release records the exact beta it was ve
299
339
  // package.json
300
340
  {
301
341
  "devDependencies": { "vite": "npm:@voidzero-dev/vite-plus-core@latest" },
302
- "pnpm": { "overrides": { "vite": "npm:@voidzero-dev/vite-plus-core@latest" } }
342
+ "pnpm": { "overrides": { "vite": "npm:@voidzero-dev/vite-plus-core@latest" } },
303
343
  }
304
344
  ```
305
345
 
package/dist/assets.d.mts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Type declarations for Pitlane's `?assets=` import convention.
2
+ * Type declarations for Pitlane's `?assets=` import convention and the
3
+ * `pitlane:dev` module.
3
4
  *
4
- * Add to an app's tsconfig to type `?assets=` imports:
5
+ * Add to an app's tsconfig to type both:
5
6
  *
6
7
  * ```jsonc
7
8
  * { "compilerOptions": { "types": ["@pitlane/dev/assets"] } }
@@ -26,3 +27,20 @@ declare module "*?assets=ssr" {
26
27
  const assets: import("@pitlane/dev/runtime").ImportedAssets;
27
28
  export default assets;
28
29
  }
30
+
31
+ declare module "pitlane:dev" {
32
+ /**
33
+ * Revalidates the page when a server-only module changes during `vite dev`,
34
+ * keeping hydrated island state. Render it once, anywhere in the document:
35
+ *
36
+ * ```tsx
37
+ * import { HMR } from "pitlane:dev";
38
+ * // ...
39
+ * <HMR />
40
+ * ```
41
+ *
42
+ * Renders nothing, and carries no client code in a production build, so it
43
+ * needs no environment guard.
44
+ */
45
+ export const HMR: () => () => null;
46
+ }
package/dist/index.d.mts CHANGED
@@ -41,6 +41,13 @@ interface RemixPluginOptions {
41
41
  * `clientEntry(import.meta.url, …)` hydration transform, dev serving through
42
42
  * the app's fetch handler, and a preview server for the production build.
43
43
  *
44
+ * During `vite dev` it also installs hot module replacement: component edits
45
+ * swap in place through the `remix/ui-hmr` transforms, and edits to modules the
46
+ * browser never loads refetch the current page through the app's fetch handler,
47
+ * keeping hydrated island state. Both are dev-only. The second half needs the
48
+ * app to render `<HMR />` from the `pitlane:dev` module, which resolves to an
49
+ * inert component in a build and when `clientEntry` is `false`.
50
+ *
44
51
  * Platform-agnostic by design: deploy targets compose alongside it in the
45
52
  * plugin array (`@cloudflare/vite-plugin`, `@netlify/vite-plugin`,
46
53
  * `nitro/vite`), or the built fetch handler runs directly on Node, Bun, and
package/dist/index.mjs CHANGED
@@ -1,9 +1,10 @@
1
- import { mergeAssets } from "./runtime.mjs";
2
1
  import fullstack from "@hiogawa/vite-plugin-fullstack";
3
2
  import * as fs from "node:fs";
4
3
  import * as path from "node:path";
4
+ import * as url from "node:url";
5
5
  import MagicString from "magic-string";
6
6
  import { parseSync } from "oxc-parser";
7
+ import { transformComponentsForBrowser, transformComponentsForServer } from "remix/ui-hmr";
7
8
  //#region src/build.ts
8
9
  function isFullstackBuilder(builder) {
9
10
  return "writeAssetsManifest" in builder && typeof builder.writeAssetsManifest === "function";
@@ -189,7 +190,7 @@ function build({ clientEntry, serverEntry }) {
189
190
  rollupOptions: { input: hasUserClientInput ? void 0 : clientEntry || void 0 }
190
191
  } } },
191
192
  ssr: {
192
- resolve: { noExternal: [/^@pitlane\/dev(\/|$)/] },
193
+ resolve: { noExternal: [/^@pitlane\/dev(\/|$)/, /^@hiogawa\/vite-plugin-fullstack(\/|$)/] },
193
194
  build: {
194
195
  outDir: "dist/ssr",
195
196
  rollupOptions: { input: hasUserServerInput ? void 0 : { index: serverEntry } }
@@ -201,12 +202,18 @@ function build({ clientEntry, serverEntry }) {
201
202
  };
202
203
  }
203
204
  const RUNTIME_MODULE_ID = "\0pitlane:runtime";
205
+ const RUNTIME_MODULE_FILTER = new RegExp(`^${RUNTIME_MODULE_ID}$`);
204
206
  /**
205
207
  * Resolves `@pitlane/dev/runtime` imports to an inlined copy of the
206
208
  * implementation. The package is a dev dependency: built output must never
207
209
  * import it at runtime (pruned containers, Deno import maps, serverless
208
210
  * bundles), and dependency-externalization behavior varies across cores and
209
211
  * orchestrators — inlining by construction removes the variable.
212
+ *
213
+ * The virtual module re-exports this package's own runtime file by absolute
214
+ * path, so the bundler inlines it and every export stays in one place. Emitting
215
+ * hand-written source here instead would drift from the real module the moment
216
+ * it gained an export.
210
217
  */
211
218
  function runtimeInline() {
212
219
  return {
@@ -219,13 +226,298 @@ function runtimeInline() {
219
226
  }
220
227
  },
221
228
  load: {
222
- filter: { id: /^\0pitlane:runtime$/ },
229
+ filter: { id: RUNTIME_MODULE_FILTER },
223
230
  handler(id) {
224
- if (id === RUNTIME_MODULE_ID) return `export const mergeAssets = ${mergeAssets.toString()};\n`;
231
+ if (id === RUNTIME_MODULE_ID) return `export * from ${JSON.stringify(runtimeImplementationPath())};\n`;
225
232
  }
226
233
  }
227
234
  };
228
235
  }
236
+ /**
237
+ * Absolute path to this package's runtime module. Sits beside the built plugin
238
+ * as `runtime.mjs` in an installed package, and beside the source as
239
+ * `runtime.ts` when the repo's own fixtures load the plugin from `src`.
240
+ */
241
+ function runtimeImplementationPath() {
242
+ let here = path.dirname(url.fileURLToPath(import.meta.url));
243
+ for (let name of ["runtime.mjs", "runtime.ts"]) {
244
+ let candidate = path.join(here, name);
245
+ if (fs.existsSync(candidate)) return candidate;
246
+ }
247
+ throw new Error(`@pitlane/dev: runtime module not found next to ${here}`);
248
+ }
249
+ //#endregion
250
+ //#region src/hmr-protocol.ts
251
+ /**
252
+ * The dev-only contract between the `serverDataHmr` plugin and the island that
253
+ * revalidates. Shared so the two cannot drift apart.
254
+ */
255
+ /** Custom Vite HMR event that asks the browser to revalidate server-rendered data. */
256
+ const SERVER_UPDATE_EVENT = "pitlane:server-update";
257
+ //#endregion
258
+ //#region src/hmr-component.ts
259
+ /** Public specifier apps import the dev HMR component from. */
260
+ const PUBLIC_ID = "pitlane:dev";
261
+ const ISLAND_ID = `\0${PUBLIC_ID}`;
262
+ const INERT_ID = `\0${PUBLIC_ID}?inert`;
263
+ /**
264
+ * The `clientEntry()` transform asks a module for its own client URL through
265
+ * `?assets=client` on its resolved id. This module answers for itself.
266
+ */
267
+ const ISLAND_ASSETS_ID = `${ISLAND_ID}?assets=client`;
268
+ const RESOLVED_ISLAND_ASSETS_ID = `\0${PUBLIC_ID}?island-assets`;
269
+ /**
270
+ * Serves the `<HMR />` component apps render in their document, which drives
271
+ * server-data revalidation.
272
+ *
273
+ * The specifier is an indirection the plugin repoints by mode. During `vite dev`
274
+ * it is a hydrated island whose handle reaches the top frame. In a build, and in
275
+ * apps with no client runtime to hydrate it, it is a component that renders
276
+ * nothing, so `<HMR />` can stay in the document unconditionally and cost
277
+ * nothing in production.
278
+ *
279
+ * Kept virtual rather than shipped as a file on disk: a file inside the package
280
+ * resolves outside the app's root, and the dev server hands out a `file://` URL
281
+ * for those, which a browser refuses to import.
282
+ */
283
+ function hmrComponent(clientEntry) {
284
+ return {
285
+ name: "pitlane-remix-hmr-component",
286
+ enforce: "pre",
287
+ resolveId(id) {
288
+ if (id === ISLAND_ASSETS_ID) return RESOLVED_ISLAND_ASSETS_ID;
289
+ if (id !== PUBLIC_ID) return;
290
+ if (this.environment.mode === "build" || clientEntry === false) return INERT_ID;
291
+ return ISLAND_ID;
292
+ },
293
+ load(id) {
294
+ if (id === INERT_ID) return INERT_SOURCE;
295
+ if (id === ISLAND_ID) return ISLAND_SOURCE;
296
+ if (id === RESOLVED_ISLAND_ASSETS_ID) return islandAssetsSource(this.environment.config.base);
297
+ }
298
+ };
299
+ }
300
+ /** A component that renders nothing and carries no client code. */
301
+ const INERT_SOURCE = `export const HMR = () => () => null;\n`;
302
+ /**
303
+ * The island. Revalidates by reloading its top frame, which refetches the page
304
+ * through the app's fetch handler and reconciles it, with no navigation
305
+ * involved. Overlapping revalidations collapse into one follow-up.
306
+ *
307
+ * Renders nothing: it contributes markup only as the hydration marker Remix
308
+ * emits around it, which is what gives it a handle in the browser.
309
+ */
310
+ const ISLAND_SOURCE = `import { clientEntry } from "remix/ui";
311
+
312
+ export const HMR = clientEntry(import.meta.url, function HMR(handle) {
313
+ if (import.meta.hot) {
314
+ let inFlight = false;
315
+ let queued = false;
316
+
317
+ let revalidate = async () => {
318
+ if (inFlight) {
319
+ queued = true;
320
+ return;
321
+ }
322
+ inFlight = true;
323
+ try {
324
+ await handle.frames.top.reload();
325
+ } finally {
326
+ inFlight = false;
327
+ }
328
+ if (queued) {
329
+ queued = false;
330
+ await revalidate();
331
+ }
332
+ };
333
+
334
+ import.meta.hot.on(${JSON.stringify(SERVER_UPDATE_EVENT)}, () => void revalidate());
335
+ }
336
+
337
+ return () => null;
338
+ });
339
+ `;
340
+ /**
341
+ * The island's own `?assets=client` answer: the dev-server URL for a virtual
342
+ * module, which is what the server writes into the hydration marker for the
343
+ * browser to import.
344
+ */
345
+ function islandAssetsSource(base) {
346
+ let url = `${base.endsWith("/") ? base : `${base}/`}@id/__x00__${PUBLIC_ID}`;
347
+ return `export default { entry: ${JSON.stringify(url)}, js: [], css: [] };\n`;
348
+ }
349
+ //#endregion
350
+ //#region src/hmr.ts
351
+ /**
352
+ * Component modules Remix authors as `.tsx`/`.jsx`. The `remix/ui-hmr` transform
353
+ * self-guards (it returns the source unchanged when a module holds no
354
+ * `function`-form component or `clientEntry`), so this filter only trims the
355
+ * common non-candidates — non-JSX source, query variants like `?assets=`, and
356
+ * dependencies — before the parse cost.
357
+ */
358
+ const COMPONENT_ID_FILTER = /\.[jt]sx$/;
359
+ /**
360
+ * How long to wait after a server module changes before asking the browser to
361
+ * revalidate. Vite applies the update to the server environment on its own
362
+ * schedule; revalidating in the same tick can reach the fetch handler while the
363
+ * server entry is still half-applied, which serves a dev error page instead of
364
+ * the new content. The delay also coalesces bursts of saves into one refetch.
365
+ */
366
+ const SERVER_UPDATE_SETTLE_MS = 50;
367
+ /**
368
+ * Instruments Remix UI components and `clientEntry()` exports with the
369
+ * `remix/ui-hmr` transforms so component edits hot-swap in place while
370
+ * preserving live component state.
371
+ *
372
+ * `ui-hmr` only recognizes named-function component forms, so arrow-form
373
+ * exports (`export const Name = clientEntry(url, (handle) => …)` and
374
+ * `export const Name = (handle) => …`) are first normalized to named function
375
+ * expressions — the idiomatic Remix authoring style then hot-swaps without any
376
+ * source changes. The normalization is discarded when `ui-hmr` does not
377
+ * instrument the module, so non-component arrows are never rewritten.
378
+ *
379
+ * The browser transform runs in the client environment and the server transform
380
+ * in the server environment(s); both emit the standard `import.meta.hot.accept()`
381
+ * protocol that Vite's own HMR runtime drives.
382
+ *
383
+ * Dev-only: production builds never carry the wrapper indirection or the runtime
384
+ * imports.
385
+ */
386
+ function componentHmr(serverEnvironments) {
387
+ return {
388
+ name: "pitlane-remix-component-hmr",
389
+ apply: "serve",
390
+ transform: {
391
+ filter: { id: {
392
+ include: COMPONENT_ID_FILTER,
393
+ exclude: /\/node_modules\//
394
+ } },
395
+ handler(code, id) {
396
+ let source = normalizeArrowComponents(code, id) ?? code;
397
+ let result = serverEnvironments.has(this.environment.name) ? transformComponentsForServer(source, {
398
+ importSource: "remix",
399
+ moduleUrl: id,
400
+ sourceMap: true
401
+ }) : transformComponentsForBrowser(source, {
402
+ importSource: "remix",
403
+ moduleUrl: id,
404
+ sourceMap: true
405
+ });
406
+ if (!result.transformed) return;
407
+ return {
408
+ code: result.code,
409
+ map: result.map
410
+ };
411
+ }
412
+ }
413
+ };
414
+ }
415
+ /**
416
+ * Server-data HMR, broadcast half: when a server-only module changes, tell the
417
+ * browser to revalidate its server-rendered content. The `<HMR />` component
418
+ * from the `pitlane:dev` module receives the event and reloads the top frame,
419
+ * which refetches the page through the app's fetch handler and reconciles it,
420
+ * keeping hydrated island state. This is the Remix 3 analog of React Router's
421
+ * loader/action revalidation.
422
+ *
423
+ * A file counts as server-only when the client graph does not serve it as a
424
+ * script; those are left to the client component-HMR boundary, so a
425
+ * `function`-form component edit still hot-swaps instantly instead of triggering
426
+ * a network reload. Only `js` client modules count: plugins that scan sources
427
+ * for other reasons (Tailwind's content scanner, for one) register `asset` nodes
428
+ * for ordinary server files, and treating those as client modules would silently
429
+ * disable server-data HMR for the whole app.
430
+ *
431
+ * Dev-only. An app that never renders `<HMR />` simply has no listener, so the
432
+ * event is inert.
433
+ */
434
+ function serverDataHmr(serverEnvironments) {
435
+ let pending;
436
+ return {
437
+ name: "pitlane-remix-server-data-hmr",
438
+ apply: "serve",
439
+ hotUpdate({ file, server }) {
440
+ if (!serverEnvironments.has(this.environment.name)) return;
441
+ if (!/\.[jt]sx?$/.test(file)) return;
442
+ if ([...(server.environments.client?.moduleGraph)?.getModulesByFile(file) ?? []].some((module) => module.type === "js")) return;
443
+ clearTimeout(pending);
444
+ pending = setTimeout(() => {
445
+ pending = void 0;
446
+ server.hot.send({
447
+ type: "custom",
448
+ event: SERVER_UPDATE_EVENT
449
+ });
450
+ }, SERVER_UPDATE_SETTLE_MS);
451
+ pending.unref?.();
452
+ }
453
+ };
454
+ }
455
+ /**
456
+ * Rewrites arrow-form component and `clientEntry()` exports to named function
457
+ * expressions so `remix/ui-hmr` can instrument them. Returns the rewritten
458
+ * source, or `undefined` when nothing qualified.
459
+ *
460
+ * Handles the two idiomatic Remix arrow forms:
461
+ *
462
+ * - `export const Name = clientEntry(url, (handle) => …)`
463
+ * - `export const Name = (handle) => () => <jsx/>`
464
+ *
465
+ * Both become `export const Name = clientEntry(url, function Name(handle) { … })`
466
+ * / `export const Name = function Name(handle) { … }`, which is behavior-
467
+ * identical for component setup functions (they never rely on a lexical `this`
468
+ * or `arguments`).
469
+ */
470
+ function normalizeArrowComponents(code, id) {
471
+ if (!code.includes("=>")) return;
472
+ let program = parseSync(id, code).program;
473
+ let rewritten;
474
+ for (let node of program.body) {
475
+ if (node.type !== "ExportNamedDeclaration") continue;
476
+ if (node.declaration?.type !== "VariableDeclaration") continue;
477
+ for (let declarator of node.declaration.declarations) {
478
+ if (declarator.id.type !== "Identifier") continue;
479
+ if (!isPascalCase(declarator.id.name)) continue;
480
+ if (!declarator.init) continue;
481
+ let arrow = getNormalizableArrow(declarator.init);
482
+ if (!arrow) continue;
483
+ rewritten ??= new MagicString(code);
484
+ let params = getParamsSource(code, arrow);
485
+ let bodySource = code.slice(arrow.body.start, arrow.body.end);
486
+ let block = arrow.body.type === "BlockStatement" ? bodySource : `{ return ${bodySource} }`;
487
+ let asyncPrefix = arrow.async ? "async " : "";
488
+ rewritten.overwrite(arrow.start, arrow.end, `${asyncPrefix}function ${declarator.id.name}${params} ${block}`);
489
+ }
490
+ }
491
+ return rewritten?.toString();
492
+ }
493
+ /**
494
+ * Returns the arrow function to normalize for a component export initializer:
495
+ * the setup argument of a `clientEntry()` call, or a bare arrow component whose
496
+ * body returns a render function. Anything else yields `undefined`.
497
+ */
498
+ function getNormalizableArrow(init) {
499
+ if (init.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "clientEntry") {
500
+ let setup = init.arguments[1];
501
+ return setup?.type === "ArrowFunctionExpression" ? setup : void 0;
502
+ }
503
+ if (init.type === "ArrowFunctionExpression" && returnsRenderFunction(init)) return init;
504
+ }
505
+ /** A Remix component setup returns a render function; that is the HMR signal. */
506
+ function returnsRenderFunction(arrow) {
507
+ let body = arrow.body;
508
+ if (body.type === "ArrowFunctionExpression" || body.type === "FunctionExpression") return true;
509
+ if (body.type === "BlockStatement") return body.body.some((statement) => statement.type === "ReturnStatement" && (statement.argument?.type === "ArrowFunctionExpression" || statement.argument?.type === "FunctionExpression"));
510
+ return false;
511
+ }
512
+ /** Source of the arrow's parameter list, always parenthesized. */
513
+ function getParamsSource(code, arrow) {
514
+ let params = arrow.params;
515
+ if (params.length === 0) return "()";
516
+ return `(${code.slice(params[0].start, params[params.length - 1].end)})`;
517
+ }
518
+ function isPascalCase(name) {
519
+ return /^[A-Z]/.test(name);
520
+ }
229
521
  //#endregion
230
522
  //#region src/preview.ts
231
523
  function isFetchHandler(value) {
@@ -344,6 +636,13 @@ function findClientEntryCalls(program) {
344
636
  * `clientEntry(import.meta.url, …)` hydration transform, dev serving through
345
637
  * the app's fetch handler, and a preview server for the production build.
346
638
  *
639
+ * During `vite dev` it also installs hot module replacement: component edits
640
+ * swap in place through the `remix/ui-hmr` transforms, and edits to modules the
641
+ * browser never loads refetch the current page through the app's fetch handler,
642
+ * keeping hydrated island state. Both are dev-only. The second half needs the
643
+ * app to render `<HMR />` from the `pitlane:dev` module, which resolves to an
644
+ * inert component in a build and when `clientEntry` is `false`.
645
+ *
347
646
  * Platform-agnostic by design: deploy targets compose alongside it in the
348
647
  * plugin array (`@cloudflare/vite-plugin`, `@netlify/vite-plugin`,
349
648
  * `nitro/vite`), or the built fetch handler runs directly on Node, Bun, and
@@ -351,6 +650,7 @@ function findClientEntryCalls(program) {
351
650
  */
352
651
  function remix(options = {}) {
353
652
  let { clientEntry = "app/entry.browser", serverEntry = "app/entry.server", serverEnvironments = ["ssr"], serverHandler = true } = options;
653
+ let serverEnvironmentSet = new Set(serverEnvironments);
354
654
  return [
355
655
  fullstack({
356
656
  serverEnvironments,
@@ -365,7 +665,10 @@ function remix(options = {}) {
365
665
  preview(),
366
666
  suppressAbortErrors(),
367
667
  normalizeWriteHead(),
368
- clientEntryTransform(new Set(serverEnvironments))
668
+ componentHmr(serverEnvironmentSet),
669
+ hmrComponent(clientEntry),
670
+ clientEntryTransform(serverEnvironmentSet),
671
+ serverDataHmr(serverEnvironmentSet)
369
672
  ];
370
673
  }
371
674
  /**
package/package.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "name": "@pitlane/dev",
3
- "version": "0.1.1",
4
- "description": "remix() \u2014 the Remix 3 Vite plugin: build orchestration, clientEntry() hydration transform, dev server, and preview for any Vite or Vite+ project.",
3
+ "version": "0.3.0",
4
+ "description": "remix() the Remix 3 Vite plugin: build orchestration, clientEntry() hydration transform, dev server with component and server-data HMR, and preview for any Vite or Vite+ project.",
5
+ "keywords": [
6
+ "pitlane",
7
+ "remix",
8
+ "vite",
9
+ "vite-plugin"
10
+ ],
5
11
  "homepage": "https://pitlane.tools/package/dev/",
6
12
  "bugs": {
7
13
  "url": "https://github.com/pitlane-tools/pitlane/issues"
@@ -13,12 +19,6 @@
13
19
  "url": "git+https://github.com/pitlane-tools/pitlane.git",
14
20
  "directory": "packages/dev"
15
21
  },
16
- "keywords": [
17
- "remix",
18
- "vite",
19
- "vite-plugin",
20
- "pitlane"
21
- ],
22
22
  "files": [
23
23
  "dist",
24
24
  "CHANGELOG.md"
@@ -46,19 +46,20 @@
46
46
  "magic-string": "^0.30.21",
47
47
  "oxc-parser": "^0.141.0"
48
48
  },
49
- "peerDependencies": {
50
- "remix": "^3.0.0-beta.5",
51
- "vite": ">=7.0.0"
52
- },
53
49
  "devDependencies": {
54
50
  "@cloudflare/vite-plugin": "^1.31.0",
55
51
  "@types/node": "^25.5.0",
56
- "remix": "3.0.0-beta.5",
52
+ "playwright": "1.61.1",
53
+ "remix": "3.0.0-beta.10",
57
54
  "typescript": "^7.0.2",
58
55
  "vite": "^8.1.5",
59
56
  "vite-plus": "^0.2.6",
60
57
  "wrangler": "^4.114.0"
61
58
  },
59
+ "peerDependencies": {
60
+ "remix": "^3.0.0-beta.10",
61
+ "vite": ">=7.0.0"
62
+ },
62
63
  "engines": {
63
64
  "node": "^20.19.0 || >=22.12.0"
64
65
  }