feat(stream): webpack plugin + bundler-agnostic factory#751
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
π WalkthroughWalkthroughRefactors streaming plugin architecture into a cross-bundler unplugin core, re-exports core primitives, adds bundler-specific wrapper entrypoints (vite/webpack/rspack/rollup), updates framework Vite wrappers to call the new Vite-specific factory, and extends package exports/build entries for new stream targets. Changes
Sequence DiagramsequenceDiagram
participant App as Bundler/App
participant Wrapper as Bundler Wrapper (vite/webpack/...)
participant Unplugin as Unplugin Core
participant VM as Virtual Module Resolver
participant HTML as HTML Injection
App->>Wrapper: initialize(options)
Wrapper->>Unplugin: createStreamingPlugin.<bundler>(options)
loop Plugin lifecycle
App->>Unplugin: resolveId(moduleId)
Unplugin->>VM: check virtual ids
VM-->>Unplugin: resolved id
Unplugin-->>App: return resolved id
App->>Unplugin: load(resolvedId)
Unplugin->>Unplugin: determine SSR mode
alt SSR
Unplugin-->>App: return SSR-safe stub
else Client
Unplugin-->>App: return client wiring or cached IIFE
end
end
loop Transform Hook
App->>Unplugin: transform(code, id, bundlerCtx/opts)
Unplugin->>Unplugin: derive ssr boolean
Unplugin->>Unplugin: call user transform({ ssr })
Unplugin-->>App: transformed result or null
end
alt Vite only
App->>Unplugin: transformIndexHtml
Unplugin->>HTML: inject script based on mode (inline / async / module)
end
Estimated code review effortπ― 4 (Complex) | β±οΈ ~50 minutes Possibly related PRs
Poem
π₯ Pre-merge checks | β 5β Passed checks (5 passed)
βοΈ Tip: You can configure your own custom pre-merge checks in the settings. β¨ Finishing Touchesπ Generate docstrings
π§ͺ Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and canβt be posted inline due to platform limitations.
β οΈ Outside diff range comments (1)
packages/unhead/src/stream/server.ts (1)
270-278:β οΈ Potential issue | π MajorAvoid treating byte chunks as safe HTML insertion points.
ReadableStream<Uint8Array>chunk boundaries can split tags, text,<script>contents, or UTF-8 sequences. Injecting a patch after everyreader.read()can corrupt output, e.g.<div cl+ patch +ass="x">. Please flush only at renderer-provided safe boundaries, or narrow/enforce the API contract so callers cannot pass arbitrary byte chunks.π€ Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/stream/server.ts` around lines 270 - 278, The loop treats raw ReadableStream<Uint8Array> chunks as safe HTML boundaries by calling flushPatch after every reader.read(); this can split UTF-8 sequences or HTML tags (reader/getReader, controller.enqueue, flushPatch). Remove the per-chunk flushPatch call and instead either (a) buffer and decode bytes with a TextDecoder (or use a TextDecoderStream) to operate on full decoded strings and only call flushPatch at renderer-provided safe boundaries, or (b) change the API so callers provide safe-string chunks or an explicit "flush" signal from the renderer; in short, stop flushing on every Uint8Array chunk and only flush when you have a guaranteed safe boundary (or when stream ends).
π§Ή Nitpick comments (2)
packages/react/src/stream/vite.ts (1)
1-4: Optional: consolidate imports fromunhead/stream/vite.Minor β
StreamingPluginOptions(type) andcreateStreamingVitePlugin(value) could share a singleimport { createStreamingVitePlugin, type StreamingPluginOptions } from 'unhead/stream/vite'statement.π€ Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/react/src/stream/vite.ts` around lines 1 - 4, Consolidate the two imports from 'unhead/stream/vite' by merging the separate type import and value import into a single statement that imports both createStreamingVitePlugin and the type StreamingPluginOptions together; update the existing import occurrences for StreamingPluginOptions and createStreamingVitePlugin to use a single combined import line so there are no duplicate module specifiers.packages/svelte/src/stream/vite.ts (1)
1-4: Optional: consolidate imports fromunhead/stream/vite.Same as React β the
typeimport on line 1 and value import on line 4 could be merged into one statement using inlinetypespecifiers.π€ Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/svelte/src/stream/vite.ts` around lines 1 - 4, Merge the two imports from 'unhead/stream/vite' into a single import statement that uses inline type specifiers: keep StreamingPluginOptions as a type and createStreamingVitePlugin as a value in the same import. Update the import that currently references StreamingPluginOptions and the separate createStreamingVitePlugin import so both symbols are imported together (e.g., using "import { StreamingPluginOptions, createStreamingVitePlugin } from 'unhead/stream/vite'" with StreamingPluginOptions marked as a type), leaving the other imports (MagicString, parseAndWalk) unchanged.
π€ Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/unhead/src/stream/vite.ts`:
- Around line 2-13: The public export was changed and breaks existing imports
expecting createStreamingPlugin; restore a compatibility alias by re-exporting
the new createStreamingVitePlugin under the old name (createStreamingPlugin) so
downstream code keeps working. Update the exports in this module to export the
existing createStreamingVitePlugin (and its types if needed) also as
createStreamingPlugin (e.g., a re-export or const alias), ensuring
createStreamingVitePlugin(options) remains the primary implementation and the
old symbol createStreamingPlugin is available for consumers.
---
Outside diff comments:
In `@packages/unhead/src/stream/server.ts`:
- Around line 270-278: The loop treats raw ReadableStream<Uint8Array> chunks as
safe HTML boundaries by calling flushPatch after every reader.read(); this can
split UTF-8 sequences or HTML tags (reader/getReader, controller.enqueue,
flushPatch). Remove the per-chunk flushPatch call and instead either (a) buffer
and decode bytes with a TextDecoder (or use a TextDecoderStream) to operate on
full decoded strings and only call flushPatch at renderer-provided safe
boundaries, or (b) change the API so callers provide safe-string chunks or an
explicit "flush" signal from the renderer; in short, stop flushing on every
Uint8Array chunk and only flush when you have a guaranteed safe boundary (or
when stream ends).
---
Nitpick comments:
In `@packages/react/src/stream/vite.ts`:
- Around line 1-4: Consolidate the two imports from 'unhead/stream/vite' by
merging the separate type import and value import into a single statement that
imports both createStreamingVitePlugin and the type StreamingPluginOptions
together; update the existing import occurrences for StreamingPluginOptions and
createStreamingVitePlugin to use a single combined import line so there are no
duplicate module specifiers.
In `@packages/svelte/src/stream/vite.ts`:
- Around line 1-4: Merge the two imports from 'unhead/stream/vite' into a single
import statement that uses inline type specifiers: keep StreamingPluginOptions
as a type and createStreamingVitePlugin as a value in the same import. Update
the import that currently references StreamingPluginOptions and the separate
createStreamingVitePlugin import so both symbols are imported together (e.g.,
using "import { StreamingPluginOptions, createStreamingVitePlugin } from
'unhead/stream/vite'" with StreamingPluginOptions marked as a type), leaving the
other imports (MagicString, parseAndWalk) unchanged.
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8eae092e-2ea7-40a6-8370-5b89623173d1
β Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
π Files selected for processing (22)
examples/vite-ssr-vue-streaming/index.htmlexamples/vite-ssr-vue-streaming/src/App.vueexamples/vite-ssr-vue-streaming/src/entry-client.tsexamples/vite-ssr-vue-streaming/tests/streaming.spec.tspackages/react/src/stream/vite.tspackages/solid-js/src/stream/vite.tspackages/svelte/src/stream/vite.tspackages/unhead/build.config.tspackages/unhead/package.jsonpackages/unhead/src/stream/rollup.tspackages/unhead/src/stream/rspack.tspackages/unhead/src/stream/server.tspackages/unhead/src/stream/unplugin.tspackages/unhead/src/stream/vite.tspackages/unhead/src/stream/webpack.tspackages/vue/build.config.tspackages/vue/package.jsonpackages/vue/src/stream/client.tspackages/vue/src/stream/server.tspackages/vue/src/stream/vite.tspackages/vue/test/streaming.test.tspackages/vue/test/vite-plugin.test.ts
π€ Files with no reviewable changes (3)
- examples/vite-ssr-vue-streaming/src/entry-client.ts
- packages/vue/package.json
- packages/vue/src/stream/client.ts
There was a problem hiding this comment.
Actionable comments posted: 2
π€ Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/unhead/src/stream/unplugin.ts`:
- Around line 12-33: Add SSR detection to the load handler and an explicit
fallback SSR flag on StreamingPluginOptions: update the StreamingPluginOptions
interface to include an optional ssr?: boolean field, change the plugin's load
hook signature from load(id) to load(id, opts?) so it accepts the Vite/Rollup
options object, and inside load use the same SSR detection logic used by
transform (extract ssr from opts or fall back to the
closure/StreamingPluginOptions.ss r) via the shared detector function used by
transform; ensure load uses that detected ssr value to decide whether to return
browser-only client code (matching transform's behavior).
- Around line 136-160: Change the transformIndexHtml export from a plain
function to an object with order: 'pre' and the original function as handler so
injected script tags for virtual modules (VIRTUAL_IIFE_ID and VIRTUAL_CLIENT_ID)
are run before HTML processing and get resolved by the plugin pipeline; update
any call sites/tests that directly invoked transformIndexHtml() (e.g.,
packages/vue/test/vite-plugin.test.ts line that calls transformIndexHtml()) to
call transformIndexHtml.handler() instead; apply the same change to the
duplicate transformIndexHtml in packages/bundler/src/devtools/vite.ts.
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5e100b44-7c57-40db-b875-587cbe8d21b3
π Files selected for processing (3)
packages/unhead/src/stream/unplugin.tstest/exports/unhead.yamltest/exports/vue.yaml
| export interface StreamingPluginOptions { | ||
| /** Framework package e.g. '@unhead/vue' */ | ||
| framework: string | ||
| /** Plugin name (optional, defaults to `${framework}:streaming`) */ | ||
| name?: string | ||
| /** | ||
| * File extension filter for transform hook, e.g. /\.vue$/. Optional β | ||
| * only required by frameworks whose client streaming support relies on | ||
| * source-level AST injection (React/Solid/Svelte). Vue does not use it. | ||
| */ | ||
| filter?: RegExp | ||
| /** Transform handler called for files matching `filter`. */ | ||
| transform?: (code: string, id: string, options?: { ssr?: boolean }) => { code: string, map?: any } | null | undefined | void | ||
| /** | ||
| * How to load the streaming client (vite-only, ignored on webpack/rspack/rollup where | ||
| * index.html injection isn't available β frameworks inject the iife themselves in SSR). | ||
| * - 'async': Load as async script (non-blocking, may have brief queue delay) | ||
| * - 'inline': Inline the IIFE directly in HTML (larger HTML, but immediate execution) | ||
| * - 'module': Use ES module import (original behavior, waits for bundle) | ||
| * @default 'async' | ||
| */ | ||
| mode?: 'async' | 'inline' | 'module' |
There was a problem hiding this comment.
π§© Analysis chain
π Script executed:
find . -type f -name "unplugin.ts" | grep -E "stream|unhead"Repository: unjs/unhead
Length of output: 95
π Script executed:
cat -n packages/unhead/src/stream/unplugin.tsRepository: unjs/unhead
Length of output: 7391
π Script executed:
fd -t f "unplugin" | head -20Repository: unjs/unhead
Length of output: 93
π Script executed:
rg "load.*handler.*\{" -A 3 --type ts | head -40Repository: unjs/unhead
Length of output: 37
π Script executed:
web_search
unplugin load hook handler signature options parameterRepository: unjs/unhead
Length of output: 152
π Web query:
unplugin load hook handler signature parameters Vite options.ssr
π‘ Result:
The unplugin load hook handler signature is (code: string, id: string) => string | null | undefined | Promise<string | null | undefined>. This matches the standard Rollup/Vite load hook, where code is the module source code and id is the module ID/path. In Vite SSR contexts, the underlying Vite/Rollup hooks for load receive an optional options parameter containing { ssr?: boolean }, allowing plugins to detect SSR mode (e.g., if (options?.ssr) { ... }). Unplugin normalizes hooks across bundlers and passes the code and id parameters to the handler as shown in source code examples for Rspack/Webpack (which extend to Vite/Rollup). The options.ssr is available in the full plugin context but not directly as a handler parameter in the normalized unplugin API; plugins detect SSR via meta.frameworkPlugin?.framework === 'vite' combined with Vite-specific logic or this.environment.config.consumer === 'server' in Vite 6+. Example usage: load: { filter: { id: /.vue$/ }, handler(code, id) { if (id.endsWith('.vue')) { // SSR-specific: if (options?.ssr) would be in raw Vite plugin return transformVueForSSR(code); } } } Vite options.ssr is not a direct parameter to the unplugin handler but is handled by unplugin's adapter layer for Vite SSR builds.
Citations:
- 1: https://unplugin.unjs.io/guide/
- 2: https://vite.dev/guide/api-plugin
- 3: https://vite.dev/guide/ssr
- 4: https://vite.dev/changes/this-environment-in-hooks
- 5: https://github.com/unjs/unplugin/blob/main/src/rspack/loaders/transform.ts
π Script executed:
npm info unplugin 2>/dev/null | grep -A 5 version || echo "Checking unplugin in node_modules"
find . -path "*/node_modules/unplugin/dist" -type d 2>/dev/null | head -1Repository: unjs/unhead
Length of output: 300
π Script executed:
# Search for unplugin type definitions or documentation
fd -t f "unplugin" -path "*/node_modules/*" 2>/dev/null | grep -E "\.(d\.ts|ts)$" | head -5Repository: unjs/unhead
Length of output: 37
π Script executed:
# Check if there are any other load handler examples in the codebase
rg "load\s*:\s*\{" -A 5 -B 1 --type tsRepository: unjs/unhead
Length of output: 3320
π Script executed:
cat node_modules/unplugin/dist/index.d.ts 2>/dev/null | grep -A 20 "load.*handler" | head -40Repository: unjs/unhead
Length of output: 37
π Script executed:
# Check the actual transform handler signature in unplugin types
cat node_modules/unplugin/package.json 2>/dev/null | grep -A 2 '"version"'Repository: unjs/unhead
Length of output: 37
π Script executed:
# Look at unplugin load/transform hook definitions
find node_modules/unplugin -name "*.d.ts" 2>/dev/null | xargs grep -l "load\|transform" | head -3 | xargs cat 2>/dev/null | head -100Repository: unjs/unhead
Length of output: 37
π Script executed:
# Check if there are examples/tests showing load hook usage
rg "load.*handler" -B 5 -A 10 --type ts packages/Repository: unjs/unhead
Length of output: 37
π Script executed:
# Look for any Vite-specific documentation or comments about hook signatures
rg "options\.ssr|opts\?" -B 3 -A 3 --type ts packages/unhead/src/stream/Repository: unjs/unhead
Length of output: 1288
π Script executed:
# Check if transform handler's opts parameter is actually used/tested
rg "opts\?" -B 2 -A 2 --type ts packages/ | head -60Repository: unjs/unhead
Length of output: 2975
Add SSR detection to load handler to match transform hook pattern.
The load handler at line 84 accepts only (id) but Vite passes an { ssr?: boolean } options parameter (as it does for transform at line 107). In Vite v6+ dev mode with per-environment contexts, the load hook only checks the mutable closure ssr, missing per-call SSR signals. This creates a gap: browser code could load in SSR contexts.
Additionally, StreamingPluginOptions has no explicit ssr field, preventing pure Rollup server builds from declaring SSR mode (webpack/rspack/vite all have hooks to set the closure, but Rollup has none).
Update the load handler signature to accept (id, opts?), extract SSR state consistently with transform using a shared detector function, and add an optional ssr?: boolean field to StreamingPluginOptions as an explicit fallback for bundlers without SSR hooks.
π€ Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/unhead/src/stream/unplugin.ts` around lines 12 - 33, Add SSR
detection to the load handler and an explicit fallback SSR flag on
StreamingPluginOptions: update the StreamingPluginOptions interface to include
an optional ssr?: boolean field, change the plugin's load hook signature from
load(id) to load(id, opts?) so it accepts the Vite/Rollup options object, and
inside load use the same SSR detection logic used by transform (extract ssr from
opts or fall back to the closure/StreamingPluginOptions.ss r) via the shared
detector function used by transform; ensure load uses that detected ssr value to
decide whether to return browser-only client code (matching transform's
behavior).
7f86cf4 to
4cdb110
Compare
There was a problem hiding this comment.
β»οΈ Duplicate comments (2)
packages/unhead/src/stream/unplugin.ts (2)
12-34:β οΈ Potential issue | π MajorUse one SSR detector for both
loadandtransform.
loadstill only reads the mutablessrclosure, so Vite dev/custom server environments can receive browser code for the virtual modules. Also includethis.environment.config.consumer === 'server'for Vite 6+ custom environment names, and add an explicitoptions.ssrfallback for Rollup/direct usage. See Vite SSR hook options and Viteβs environment migration docs: https://vite.dev/guide/ssr#ssr-specific-plugin-logic, https://vite.dev/changes/this-environment-in-hooks.π§ Proposed fix
export interface StreamingPluginOptions { /** Framework package e.g. '@unhead/vue' */ framework: string @@ * `@default` 'async' */ mode?: 'async' | 'inline' | 'module' + /** Explicit SSR fallback for bundlers/tests that do not provide a hook-level SSR signal. */ + ssr?: boolean } @@ export function buildStreamingPluginOptions(options: StreamingPluginOptions): UnpluginOptions { const { framework, name, mode = 'async' } = options let ssr = false + + const isSsrContext = (ctx?: any, opts?: { ssr?: boolean }) => { + const environment = ctx?.environment + return environment?.config?.consumer === 'server' + || environment?.name === 'ssr' + || environment?.name === 'server' + || opts?.ssr === true + || options.ssr === true + || ssr + } @@ load: { filter: { id: RESOLVED_RE }, - handler(id) { + handler(this: any, id: string, opts?: { ssr?: boolean }) { + const isSSR = isSsrContext(this, opts) if (id === RESOLVED_ID) { - if (ssr) + if (isSSR) return { code: 'export {}' } @@ } if (id === RESOLVED_IIFE_ID) { - if (ssr) + if (isSSR) return { code: '' } @@ handler(this: any, code: string, id: string, opts?: { ssr?: boolean }) { - // Vite v6+ dev mode exposes environment per-transform call (one plugin - // instance, two environments). Fall back to the options.ssr flag - // (vite <=5 and tests), then the bundler-hook closure for - // vite build (separate instances per build) and webpack/rspack. - const envName = this?.environment?.name - const isSSR = envName === 'ssr' || envName === 'server' || opts?.ssr === true || ssr + const isSSR = isSsrContext(this, opts) return options.transform!(code, id, { ssr: isSSR }) },Also applies to: 82-100, 107-114
π€ Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/stream/unplugin.ts` around lines 12 - 34, Create a single SSR detection helper and use it from both the load and transform hooks: detect SSR by checking the existing mutable ssr closure (if defined and truthy), OR this.environment.config.consumer === 'server' (for Vite 6+ custom envs), OR the options?.ssr boolean fallback (for Rollup/direct API usage); replace the current ad-hoc checks in load and transform so both call this helper to decide whether to return browser or server code and ensure load no longer reads only the mutable ssr closure.
136-160:β οΈ Potential issue | π MajorRun virtual-module HTML injection before Vite processes HTML scripts.
These injected scripts reference
/${VIRTUAL_IIFE_ID}and/${VIRTUAL_CLIENT_ID}. ViteβstransformIndexHtmldocs reserveorder: 'pre'for injected scripts that must pass through the plugin pipeline; otherwise production HTML can retain raw/virtual:URLs. See https://vite.dev/guide/api-plugin#transformindexhtml.π§ Proposed fix
- transformIndexHtml() { - if (mode === 'inline') { - if (!iifeCode) - throw new Error('[unhead] Streaming IIFE not built. Run `pnpm build` in packages/unhead first.') - return [{ - tag: 'script', - children: iifeCode, - injectTo: 'head-prepend', - }] - } - - if (mode === 'async') { - return [{ - tag: 'script', - attrs: { async: true, src: `/${VIRTUAL_IIFE_ID}` }, - injectTo: 'head-prepend', - }] - } - - return [{ - tag: 'script', - children: `import("/${VIRTUAL_CLIENT_ID}")`, - injectTo: 'head-prepend', - }] + transformIndexHtml: { + order: 'pre', + handler() { + if (mode === 'inline') { + if (!iifeCode) + throw new Error('[unhead] Streaming IIFE not built. Run `pnpm build` in packages/unhead first.') + return [{ + tag: 'script', + children: iifeCode, + injectTo: 'head-prepend', + }] + } + + if (mode === 'async') { + return [{ + tag: 'script', + attrs: { async: true, src: `/${VIRTUAL_IIFE_ID}` }, + injectTo: 'head-prepend', + }] + } + + return [{ + tag: 'script', + children: `import("/${VIRTUAL_CLIENT_ID}")`, + injectTo: 'head-prepend', + }] + }, },Run this read-only check for direct test/call-site invocations that need to switch to
.handler(...)after this shape change:#!/bin/bash rg -nP '\.transformIndexHtml\s*\(' --type ts -C3π€ Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/stream/unplugin.ts` around lines 136 - 160, The injected script objects returned from transformIndexHtml must be marked to run before Vite processes HTML scripts so virtual-module URLs are transformed; update the three return objects in transformIndexHtml (the inline iifeCode branch, the async branch that uses VIRTUAL_IIFE_ID, and the default branch that imports VIRTUAL_CLIENT_ID) to include order: 'pre' on each injected script descriptor; also scan for any direct calls to transformIndexHtml and update them to use the new handler shape (call .handler(...) where needed) so callers match the changed plugin API.
π€ Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@packages/unhead/src/stream/unplugin.ts`:
- Around line 12-34: Create a single SSR detection helper and use it from both
the load and transform hooks: detect SSR by checking the existing mutable ssr
closure (if defined and truthy), OR this.environment.config.consumer ===
'server' (for Vite 6+ custom envs), OR the options?.ssr boolean fallback (for
Rollup/direct API usage); replace the current ad-hoc checks in load and
transform so both call this helper to decide whether to return browser or server
code and ensure load no longer reads only the mutable ssr closure.
- Around line 136-160: The injected script objects returned from
transformIndexHtml must be marked to run before Vite processes HTML scripts so
virtual-module URLs are transformed; update the three return objects in
transformIndexHtml (the inline iifeCode branch, the async branch that uses
VIRTUAL_IIFE_ID, and the default branch that imports VIRTUAL_CLIENT_ID) to
include order: 'pre' on each injected script descriptor; also scan for any
direct calls to transformIndexHtml and update them to use the new handler shape
(call .handler(...) where needed) so callers match the changed plugin API.
βΉοΈ Review info
βοΈ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: acb79e71-688b-4e23-bb18-f135c2fe0c41
β Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
π Files selected for processing (12)
packages/react/src/stream/vite.tspackages/solid-js/src/stream/vite.tspackages/svelte/src/stream/vite.tspackages/unhead/build.config.tspackages/unhead/package.jsonpackages/unhead/src/stream/rollup.tspackages/unhead/src/stream/rspack.tspackages/unhead/src/stream/unplugin.tspackages/unhead/src/stream/vite.tspackages/unhead/src/stream/webpack.tspackages/vue/src/stream/vite.tstest/exports/unhead.yaml
β Files skipped from review due to trivial changes (1)
- packages/unhead/build.config.ts
π§ Files skipped from review as they are similar to previous changes (9)
- packages/unhead/src/stream/webpack.ts
- packages/vue/src/stream/vite.ts
- packages/unhead/src/stream/rollup.ts
- packages/unhead/src/stream/rspack.ts
- packages/react/src/stream/vite.ts
- packages/unhead/src/stream/vite.ts
- packages/svelte/src/stream/vite.ts
- packages/unhead/package.json
- test/exports/unhead.yaml
Extracts the streaming plugin hooks into a bundler-agnostic unplugin factory (`packages/unhead/src/stream/unplugin.ts`) and exposes per-bundler entry points: - `unhead/stream/vite` β `createStreamingVitePlugin` - `unhead/stream/webpack` β `createStreamingWebpackPlugin` - `unhead/stream/rspack` β `createStreamingRspackPlugin` - `unhead/stream/rollup` β `createStreamingRollupPlugin` SSR detection is wired per bundler: - vite: `ConfigEnv.isSsrBuild`, plus per-transform `environment.name` in vite v6+ dev - webpack/rspack: `compiler.options.name === 'server'` React/Solid/Svelte/Vue vite plugins now delegate to `createStreamingVitePlugin`; the old standalone `createStreamingPlugin` export is removed.
4cdb110 to
7feae06
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
π€ Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/unhead/src/stream/unplugin.ts`:
- Around line 120-128: Update the SSR detection in the webpack(compiler) and
rspack(compiler) hooks to check compiler.options.target in addition to
compiler.options.name === 'server' (e.g., treat target values 'node' or
'async-node' as SSR); do not reference options.ssr unless you first extend the
StreamingPluginOptions interface to include an explicit ssr overrideβeither add
that field to the interface and honor it, or rely solely on
compiler.options.name/target checks in the webpack and rspack functions (refer
to the webpack and rspack function names in the diff).
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e3c385d1-7fe8-48ca-af8b-bcc45350b937
β Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
π Files selected for processing (12)
packages/react/src/stream/vite.tspackages/solid-js/src/stream/vite.tspackages/svelte/src/stream/vite.tspackages/unhead/build.config.tspackages/unhead/package.jsonpackages/unhead/src/stream/rollup.tspackages/unhead/src/stream/rspack.tspackages/unhead/src/stream/unplugin.tspackages/unhead/src/stream/vite.tspackages/unhead/src/stream/webpack.tspackages/vue/src/stream/vite.tstest/exports/unhead.yaml
β Files skipped from review due to trivial changes (2)
- packages/unhead/src/stream/webpack.ts
- packages/unhead/build.config.ts
π§ Files skipped from review as they are similar to previous changes (4)
- packages/unhead/src/stream/rollup.ts
- packages/unhead/src/stream/rspack.ts
- test/exports/unhead.yaml
- packages/unhead/package.json
β¦per bundler
Drops the per-framework /stream/{bundler} subpaths in favour of a single
unified Unhead() factory exposed at @unhead/{framework}/{bundler}, with
streaming opt-in via a feature flag. One plugin per (framework, bundler)
combination matches Vite/Webpack community conventions and lets the build
optimisations (tree-shaking, useSeoMeta transform, minify) compose with the
streaming hooks behind one import.
@unhead/bundler
- Add /rspack and /rollup unified-plugin entries (mirror of /webpack)
@unhead/{vue,react,svelte,solid-js}
- Refactor stream plugin to an unplugin instance at src/stream/plugin.ts,
exposing .vite()/.webpack()/.rspack()/.rollup()/.esbuild() builders
- Add unified Unhead() per bundler at src/{vite,webpack,rspack,rollup}.ts
that composes @unhead/bundler + the framework streaming plugin
- Drop @unhead/{framework}/stream/vite (no replacement; use the unified
/{bundler} entry with streaming: true)
- Add unplugin as a direct dep
unhead
- Drop /stream/{vite,webpack,rspack,rollup} (trivial wrappers, redundant
now that frameworks consume the unplugin factory directly)
- Expose /stream/unplugin (buildStreamingPluginOptions, createStreamingPlugin,
virtual id constants) as the single canonical core entry for plugin authors
docs
- Update Vue/React/Solid/Svelte streaming guides to the new Unhead() API
β¦eam/vite
Restores @unhead/{vue,react,solid-js,svelte}/stream/vite as a deprecation
shim re-exporting the new unheadXxxStreamingPlugin via its .vite() adapter
under the old unheadXxxPlugin name. Prevents the 751 rename from being a
breaking change; users are nudged to Unhead({ streaming: true }) via JSDoc
@deprecated. The shim will be removed in a future major.
Tests
- Extend each framework's vite-plugin.test.ts with smoke tests for the
.webpack/.rspack/.rollup adapters on the unplugin instance
- Add unified-plugin.test.ts per framework verifying Unhead({ streaming: true })
returns a plugin array with the streaming plugin present on each bundler
- Confirm the deprecated unheadXxxPlugin alias still returns a valid vite plugin
vitest
- Add @unhead/bundler/{vite,webpack,rspack,rollup} aliases to vue's vitest
config so the new per-bundler unified entries resolve to source
Three issues surfaced by CodeRabbit on PR 751:
1. load hook missed Vite v6+ per-environment SSR signal. Extract a shared
isSSRCall(this, opts) detector (envName + opts.ssr + closure fallback)
and use it from both load and transform. Prevents serving browser
bootstrap code into SSR contexts in dev.
2. transformIndexHtml ran at default order, separate from the plugin-level
enforce: 'pre'. Wrap in { order: 'pre', handler } so our virtual-module
<script> tags are injected before non-pre HTML transforms touch them.
Apply the same fix to the devtools bundler vite plugin which injects a
bridge import the same way.
3. webpack/rspack SSR detection checked only compiler.options.name === 'server',
which is convention not universal. Also treat target: 'node' /
'async-node' as SSR. Matches how webpack SSR is typically configured.
Vue vite-plugin test updated to call transformIndexHtml.handler() now that
transformIndexHtml is an object, and asserts order === 'pre'.
Code reviewFound 1 issue:
Previously at line 76: unhead/packages/unhead/package.json Lines 74 to 80 in 40f777b Current state at that line: unhead/packages/unhead/package.json Lines 74 to 80 in 180f44f π€ Generated with Claude Code - If this code review was useful, please react with π. Otherwise, react with π. |
β¦moduleType Two regressions surfaced by review: 1. unhead/stream/vite subpath (on main v3.0.5) was removed outright while the framework-level /stream/vite entries got deprecation shims. Restore it as a stub re-exporting VIRTUAL_CLIENT_ID, VIRTUAL_IIFE_ID, StreamingPluginOptions, buildStreamingPluginOptions from ./unplugin, and exposing createStreamingPlugin as a vite-plugin function backed by .vite() on the unplugin instance. Marked @deprecated with a migration snippet pointing at unhead/stream/unplugin. Wired back through package.json exports/typesVersions and build.config entries. 2. moduleType: 'js' was dropped from the load hook returns. It was explicitly added in df9c846 for Rolldown compatibility with virtual modules (Rolldown requires explicit moduleType; other bundlers ignore it). Restored on all four branches (RESOLVED_ID / RESOLVED_IIFE_ID, SSR / client) with a comment citing the original fix. Exports snapshot picks up the new ./stream/vite: shape alongside ./stream/unplugin: so both consumers can keep working.
Each framework package (@unhead/{vue,react,solid-js,svelte}) now exposes
per-bundler entries (/vite, /webpack, /rspack, /rollup). Previously only
`vite` was declared as an optional peer. Add `webpack`, `@rspack/core`, and
`rollup` alongside for:
- Better install-time signals when a bundler subpath is imported without
the bundler installed.
- Consistency with the existing optional-peer pattern.
- TypeScript types resolution for bundler-specific return types.
@unhead/bundler also gets `webpack`, `@rspack/core`, `rollup`, and `vite`
added as optional peers to match its /webpack, /rspack, /rollup, /vite
subpath entries.
Version floors picked to cover the realistic min versions: webpack >=5,
@rspack/core >=1, rollup >=4. vite stays at >=6.4.2.
Folds the per-framework Unhead() wrappers into a shared factory in @unhead/bundler/framework so each framework/bundler entry becomes a thin parameterization instead of 20 near-identical files. Extends the multi-bundler story with esbuild and rolldown entries across the bundler and framework packages. Streaming plugin: - Add `nonce: string | () => string` option forwarded on every injected <script> tag; createBootstrapScript() accepts a nonce too. - Add dev-mode warning when the client IIFE runs but the server bootstrap queue never populated window[streamKey]. - Fix 'async' mode for production Vite builds: emit the IIFE via this.emitFile() so the <script src> references a real hashed asset instead of the virtual /virtual:@unhead/streaming-iife.js URL. - Default mode changed from 'async' to 'inline' (smallest TTFB, always safe in production). - Extract a shared StreamingGlobal type in unhead/stream/types so the server bootstrap, client, and injected IIFE agree on the shape of window.__unhead__ (was a stringly-typed contract). Plugin factory: - Move `_framework` off the public VitePluginOptions surface; it now flows through a separate internal 2nd arg to the base factory. - Typed Plugin[] return on rollup entry; drop the `| false` tri-state on `streaming` option. Tests: - New regression guard asserts the streaming plugin runs last in the vite plugin array (enforce: 'pre' dependency on prior transforms). Docs + snapshots updated.
Scopes the streaming plugin work down to vite + webpack. Power users on
rspack/rollup/esbuild/rolldown reach for the framework's streaming
unplugin instance directly (e.g. `unheadVueStreamingPlugin.rspack()`),
which is still re-exported from `@unhead/{fw}/stream/vite`.
Removes:
- `@unhead/{react,solid-js,svelte,vue}/{rspack,rollup,esbuild,rolldown}`
- `@unhead/bundler/{rspack,rollup,esbuild,rolldown}`
- The matching `createFramework{Rspack,Rollup,Esbuild,Rolldown}Plugin`
helpers from `@unhead/bundler/framework`
- `@rspack/core`, `rollup` peer-dep declarations from framework packages
π Linked issue
N/A β surfaced while filling in the missing webpack story for the streaming plugin.
β Type of change
π Description
Anyone on webpack had no way to wire the streaming bootstrap; only
unhead/stream/viteexisted. Extracted the plugin into a bundler-agnostic unplugin factory and added webpack alongside the existing vite entry.Framework packages (
@unhead/{react,solid-js,svelte,vue}) gain a matching./webpackentry soUnhead({ streaming: true })works on webpack.For other bundlers (rspack, rollup, esbuild, rolldown), the framework packages still expose the streaming plugin as a raw
unplugininstance via the existing@unhead/{fw}/stream/vitere-export. Power users can callunheadVueStreamingPlugin.rspack(),.rollup(), etc. directly.Other streaming changes in this PR:
nonceoption on the streaming plugin andcreateBootstrapScript()β acceptsstring | () => string | undefinedand is forwarded on every injected<script>tag (CSP support).asyncmode fix for production Vite builds β IIFE is now emitted viathis.emitFile()so the<script src>references a real hashed asset instead of the virtual/virtual:@unhead/streaming-iife.jsURL.StreamingGlobaltype atunhead/stream/typesso the server bootstrap, client, and injected IIFE agree on the shape ofwindow.__unhead__(was a stringly-typed contract).window[streamKey].@unhead/bundlerpeer dep floors. New optional peer constraints:vite >=6.4.2,webpack >=5.0.0. Users on older Vite (e.g. <6.4.2) will see peer-dep warnings.VitePluginOptions._frameworkremoved. Was@internal; now threaded via a second argument to the baseUnhead()factory (InternalFrameworkContext). Framework wrappers usecreateFrameworkVitePluginhelpers, so this only affects anyone calling@unhead/bundler/vite'sUnhead()directly with_framework.UnheadXxxViteOptions.streamingtype narrowed.streaming?: true | Pick<StreamingPluginOptions, 'mode'> | falseβstreaming?: true | UnheadXxxStreamingOptions.falseis no longer a valid TS value; omit the key or passundefined. Runtime behaviour is unchanged.Deprecation shims are retained so existing imports keep working:
unhead/stream/vitestill exportscreateStreamingPlugin,VIRTUAL_CLIENT_ID,VIRTUAL_IIFE_ID,StreamingPluginOptions.@unhead/{react,solid-js,svelte,vue}/stream/vitestill exportsunheadXxxPlugin()with its prior signature, plus the rawunheadXxxStreamingPluginunplugin instance for other bundlers.π Migration
Prefer the unified per-bundler entries:
For webpack, use
@unhead/{react,solid-js,svelte,vue}/webpack. For rspack, rollup, esbuild, or rolldown, reach for the underlying unplugin instance: