Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions packages/runtime-core/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,28 @@ export function registerRuntimeCompiler(_compile: any): void {
// dev only
export const isRuntimeOnly = (): boolean => !compile

/**
* @internal
*/
export function getResolvedCompilerOptions(
instance: ComponentInternalInstance,
): CompilerOptions {
const Component = instance.type as ComponentOptions
const { isCustomElement, compilerOptions } = instance.appContext.config
const { delimiters, compilerOptions: componentCompilerOptions } = Component

return extend(
extend(
{
isCustomElement,
delimiters,
},
compilerOptions,
),
componentCompilerOptions,
)
}

export function finishComponentSetup(
instance: ComponentInternalInstance,
isSSR: boolean,
Expand Down Expand Up @@ -1021,19 +1043,7 @@ export function finishComponentSetup(
if (__DEV__) {
startMeasure(instance, `compile`)
}
const { isCustomElement, compilerOptions } = instance.appContext.config
const { delimiters, compilerOptions: componentCompilerOptions } =
Component
const finalCompilerOptions: CompilerOptions = extend(
extend(
{
isCustomElement,
delimiters,
},
compilerOptions,
),
componentCompilerOptions,
)
const finalCompilerOptions = getResolvedCompilerOptions(instance)
if (__COMPAT__) {
// pass runtime compat config into the compiler
finalCompilerOptions.compatConfig = Object.create(globalCompatConfig)
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ export { transformVNodeArgs } from './vnode'
import {
createComponentInstance,
getComponentPublicInstance,
getResolvedCompilerOptions,
setupComponent,
} from './component'
import { renderComponentRoot } from './componentRenderUtils'
Expand All @@ -416,6 +417,7 @@ const _ssrUtils: {
ensureValidVNode: typeof ensureValidVNode
pushWarningContext: typeof pushWarningContext
popWarningContext: typeof popWarningContext
getResolvedCompilerOptions: typeof getResolvedCompilerOptions
} = {
createComponentInstance,
setupComponent,
Expand All @@ -427,6 +429,7 @@ const _ssrUtils: {
ensureValidVNode,
pushWarningContext,
popWarningContext,
getResolvedCompilerOptions,
}

/**
Expand Down
69 changes: 69 additions & 0 deletions packages/server-renderer/__tests__/nodeStream.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createApp, defineAsyncComponent, h } from 'vue'
import { pipeToNodeWritable, renderToNodeStream } from '../src'
import { Writable } from 'node:stream'
import { describe, expect, test } from 'vitest'

describe('Node.js Streams backpressure', () => {
test('pipeToNodeWritable backpressure', async () => {
const Async = defineAsyncComponent(() =>
Promise.resolve({
render: () => h('div', 'b'),
}),
)
const App = {
render: () => [h('div', 'a'), h(Async)],
}

let writeCount = 0
const writable = new Writable({
highWaterMark: 1,
write(_chunk, _encoding, callback) {
writeCount++
callback()
},
})

const originalWrite = writable.write.bind(writable)
let firstCall = true
writable.write = (chunk: any, encoding?: any, cb?: any): any => {
if (firstCall) {
firstCall = false
originalWrite(chunk, encoding, cb)
return false
}
return originalWrite(chunk, encoding, cb)
}

pipeToNodeWritable(createApp(App), {}, writable)

await new Promise(resolve => setTimeout(resolve, 20))
// Should have only 1 write because it returned false and we're waiting for drain
expect(writeCount).toBe(1)

writable.emit('drain')
await new Promise(resolve => setTimeout(resolve, 20))
Comment on lines +18 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the backpressure test control drain deterministically.

Calling the original Writable.write() and immediately invoking its callback lets Node emit a real drain before Line 43, so the Line 41 assertion can race and fail before the manual drain.

Proposed deterministic gate
     let writeCount = 0
+    let unblockFirstWrite: (() => void) | undefined
     const writable = new Writable({
       highWaterMark: 1,
       write(_chunk, _encoding, callback) {
         writeCount++
+        if (writeCount === 1) {
+          unblockFirstWrite = callback
+          return
+        }
         callback()
       },
     })
-
-    const originalWrite = writable.write.bind(writable)
-    let firstCall = true
-    writable.write = (chunk: any, encoding?: any, cb?: any): any => {
-      if (firstCall) {
-        firstCall = false
-        originalWrite(chunk, encoding, cb)
-        return false
-      }
-      return originalWrite(chunk, encoding, cb)
-    }
 
     pipeToNodeWritable(createApp(App), {}, writable)
 
     await new Promise(resolve => setTimeout(resolve, 20))
     // Should have only 1 write because it returned false and we're waiting for drain
     expect(writeCount).toBe(1)
 
+    expect(unblockFirstWrite).toBeDefined()
+    unblockFirstWrite!()
     writable.emit('drain')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server-renderer/__tests__/nodeStream.spec.ts` around lines 18 - 44,
The test races because calling originalWrite immediately lets Node emit a real
drain; change the writable.write override (the one using originalWrite,
firstCall and writeCount) so that on the first call you call
originalWrite(chunk, encoding, wrappedCb) where wrappedCb saves the real
callback into a local heldCb variable but does NOT invoke it, and return false;
after the expect(writeCount).toBe(1) invoke heldCb() and then emit('drain') on
writable (keeping the existing writable.emit('drain') call) so the drain is
triggered deterministically.

// Second write should have happened after drain
expect(writeCount).toBeGreaterThan(1)
})

test('renderToNodeStream backpressure', async () => {
const Async = defineAsyncComponent(() =>
Promise.resolve({
render: () => h('div', 'b'),
}),
)
const App = {
render: () => [h('div', 'a'), h(Async)],
}

const stream = renderToNodeStream(createApp(App))

// In Node.js Readable, push() returns false when the buffer is full.
// For our test, we'll just verify that it streams correctly first.
let res = ''
for await (const chunk of stream) {
res += chunk
}
expect(res).toBe('<!--[--><div>a</div><div>b</div><!--]-->')
})
})
82 changes: 82 additions & 0 deletions packages/server-renderer/__tests__/webStream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,85 @@ test('pipeToWebWritable', async () => {

expect(res).toBe(`<!--[--><div>parent</div><div>async</div><!--]-->`)
})

test('pipeToWebWritable error handling', async () => {
const App = {
ssrRender() {
throw new Error('ssr render error')
},
}

let abortedReason: any
const writable = new WritableStream({
abort(reason) {
abortedReason = reason
},
})

pipeToWebWritable(createApp(App), {}, writable)

// Wait for the error to propagate
await new Promise(resolve => setTimeout(resolve, 10))

expect(abortedReason).toBeInstanceOf(Error)
expect(abortedReason.message).toBe('ssr render error')
Comment on lines +82 to +88
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Flaky wait — prefer event-driven synchronization over a fixed 10 ms timeout.

setTimeout(resolve, 10) is a race; under CI load (slow machines, GC, microtask backlog) the abort may not have propagated yet, producing false negatives (abortedReason still undefined). Use a promise resolved from inside abort():

🧪 Proposed fix
   let abortedReason: any
+  let resolveAbort!: () => void
+  const aborted = new Promise<void>(r => (resolveAbort = r))
   const writable = new WritableStream({
     abort(reason) {
       abortedReason = reason
+      resolveAbort()
     },
   })

   pipeToWebWritable(createApp(App), {}, writable)

-  // Wait for the error to propagate
-  await new Promise(resolve => setTimeout(resolve, 10))
+  await aborted

   expect(abortedReason).toBeInstanceOf(Error)
   expect(abortedReason.message).toBe('ssr render error')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pipeToWebWritable(createApp(App), {}, writable)
// Wait for the error to propagate
await new Promise(resolve => setTimeout(resolve, 10))
expect(abortedReason).toBeInstanceOf(Error)
expect(abortedReason.message).toBe('ssr render error')
let abortedReason: any
let resolveAbort!: () => void
const aborted = new Promise<void>(r => (resolveAbort = r))
const writable = new WritableStream({
abort(reason) {
abortedReason = reason
resolveAbort()
},
})
pipeToWebWritable(createApp(App), {}, writable)
await aborted
expect(abortedReason).toBeInstanceOf(Error)
expect(abortedReason.message).toBe('ssr render error')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server-renderer/__tests__/webStream.spec.ts` around lines 82 - 88,
The test uses a fixed 10ms setTimeout to wait for the abort which is flaky;
replace the timeout with an event-driven promise that resolves when the
Writable's abort handler runs so the test waits exactly for the abort.
Specifically, in the test that calls pipeToWebWritable(createApp(App), {},
writable) replace the sleep with a Promise whose resolver is called from the
writable.abort (or the assigned abort callback) where you already set
abortedReason; ensure the promise resolves inside that abort handler so the
subsequent expects (abortedReason and its message) run only after abort() has
executed.

})

test('pipeToWebWritable backpressure', async () => {
const Async = defineAsyncComponent(() =>
Promise.resolve({
render: () => h('div', 'b'),
}),
)
const App = {
render: () => [h('div', 'a'), h(Async)],
}

let writeCount = 0
let resolveWrite: any
const writable = new WritableStream({
write() {
writeCount++
return new Promise(resolve => {
resolveWrite = resolve
})
},
})

pipeToWebWritable(createApp(App), {}, writable)

await new Promise(resolve => setTimeout(resolve, 20))
// Should have only 1 write because the first one is pending
expect(writeCount).toBe(1)

resolveWrite()
await new Promise(resolve => setTimeout(resolve, 20))
// Second write should have happened after the async component resolved
expect(writeCount).toBeGreaterThan(1)
})

test('renderToWebStream backpressure', async () => {
const Async = defineAsyncComponent(() =>
Promise.resolve({
render: () => h('div', 'b'),
}),
)
const App = {
render: () => [h('div', 'a'), h(Async)],
}

const stream = renderToWebStream(createApp(App), {})
const reader = stream.getReader()

const { value: v1 } = await reader.read()
expect(new TextDecoder().decode(v1)).toBe('<!--[--><div>a</div>')

const { value: v2 } = await reader.read()
expect(new TextDecoder().decode(v2)).toBe('<div>b</div>')

const { value: v3 } = await reader.read()
expect(new TextDecoder().decode(v3)).toBe('<!--]-->')

const { done } = await reader.read()
expect(done).toBe(true)
})
17 changes: 2 additions & 15 deletions packages/server-renderer/src/helpers/ssrCompile.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {
type ComponentInternalInstance,
type ComponentOptions,

Check failure on line 3 in packages/server-renderer/src/helpers/ssrCompile.ts

View workflow job for this annotation

GitHub Actions / test / lint-and-test-dts

'ComponentOptions' is declared but its value is never read.
ssrUtils,
warn,
} from 'vue'
import { compile } from '@vue/compiler-ssr'
import { NO, extend, generateCodeFrame, isFunction } from '@vue/shared'

Check failure on line 8 in packages/server-renderer/src/helpers/ssrCompile.ts

View workflow job for this annotation

GitHub Actions / test / lint-and-test-dts

'extend' is declared but its value is never read.
import type { CompilerError, CompilerOptions } from '@vue/compiler-core'

Check failure on line 9 in packages/server-renderer/src/helpers/ssrCompile.ts

View workflow job for this annotation

GitHub Actions / test / lint-and-test-dts

'CompilerOptions' is declared but never used.
import type { PushFn } from '../render'

import * as Vue from 'vue'
Expand All @@ -32,21 +33,7 @@
)
}

// TODO: This is copied from runtime-core/src/component.ts and should probably be refactored
const Component = instance.type as ComponentOptions
const { isCustomElement, compilerOptions } = instance.appContext.config
const { delimiters, compilerOptions: componentCompilerOptions } = Component

const finalCompilerOptions: CompilerOptions = extend(
extend(
{
isCustomElement,
delimiters,
},
compilerOptions,
),
componentCompilerOptions,
)
const finalCompilerOptions = ssrUtils.getResolvedCompilerOptions(instance)

finalCompilerOptions.isCustomElement =
finalCompilerOptions.isCustomElement || NO
Expand Down
Loading
Loading