Skip to content

Commit

Permalink
Merge branch 'main' into tobbe-rsc-fix-analyze-warning
Browse files Browse the repository at this point in the history
  • Loading branch information
Tobbe authored Mar 15, 2024
2 parents 463c0a5 + 25b24c8 commit 2dccca8
Show file tree
Hide file tree
Showing 9 changed files with 1,574 additions and 2 deletions.
3 changes: 3 additions & 0 deletions packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
"test:watch": "vitest watch src"
},
"dependencies": {
"@babel/generator": "7.23.6",
"@babel/parser": "^7.22.16",
"@babel/traverse": "^7.22.20",
"@redwoodjs/babel-config": "workspace:*",
"@redwoodjs/internal": "workspace:*",
"@redwoodjs/project-config": "workspace:*",
Expand Down
4 changes: 3 additions & 1 deletion packages/vite/src/buildRscClientAndServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { rscBuildRwEnvVars } from './rsc/rscBuildRwEnvVars.js'

export const buildRscClientAndServer = async () => {
// Analyze all files and generate a list of RSCs and RSFs
const { clientEntryFiles, serverEntryFiles } = await rscBuildAnalyze()
const { clientEntryFiles, serverEntryFiles, componentImportMap } =
await rscBuildAnalyze()

// Generate the client bundle
const clientBuildOutput = await rscBuildClient(clientEntryFiles)
Expand All @@ -17,6 +18,7 @@ export const buildRscClientAndServer = async () => {
clientEntryFiles,
serverEntryFiles,
{},
componentImportMap,
)

// Copy CSS assets from server to client
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions packages/vite/src/plugins/vite-plugin-rsc-analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ import path from 'node:path'
import * as swc from '@swc/core'
import type { Plugin } from 'vite'

import { getPaths } from '@redwoodjs/project-config'

export function rscAnalyzePlugin(
clientEntryCallback: (id: string) => void,
serverEntryCallback: (id: string) => void,
componentImportsCallback: (id: string, importId: readonly string[]) => void,
): Plugin {
const clientEntryIdSet = new Set<string>()
const webSrcPath = getPaths().web.src

return {
name: 'rsc-analyze-plugin',
transform(code, id) {
Expand All @@ -25,6 +31,7 @@ export function rscAnalyzePlugin(
) {
if (item.expression.value === 'use client') {
clientEntryCallback(id)
clientEntryIdSet.add(id)
} else if (item.expression.value === 'use server') {
serverEntryCallback(id)
}
Expand All @@ -34,5 +41,11 @@ export function rscAnalyzePlugin(

return code
},
moduleParsed(moduleInfo) {
// TODO: Maybe this is not needed?
if (moduleInfo.id.startsWith(webSrcPath)) {
componentImportsCallback(moduleInfo.id, moduleInfo.importedIds)
}
},
}
}
240 changes: 240 additions & 0 deletions packages/vite/src/plugins/vite-plugin-rsc-css-preinit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import fs from 'fs'
import path from 'path'

import generate from '@babel/generator'
import { parse as babelParse } from '@babel/parser'
import type { NodePath } from '@babel/traverse'
import traverse from '@babel/traverse'
import * as t from '@babel/types'
import type { Plugin } from 'vite'

import { getPaths } from '@redwoodjs/project-config'

export function generateCssMapping(clientBuildManifest: any) {
const clientBuildManifestCss = new Map<string, string[]>()
const lookupCssAssets = (id: string): string[] => {
const assets: string[] = []
const asset = clientBuildManifest[id]
if (!asset) {
return assets
}
if (asset.css) {
assets.push(...asset.css)
}
if (asset.imports) {
for (const importId of asset.imports) {
assets.push(...lookupCssAssets(importId))
}
}
return assets
}
for (const key of Object.keys(clientBuildManifest)) {
clientBuildManifestCss.set(key, lookupCssAssets(key))
}
return clientBuildManifestCss
}

export function splitClientAndServerComponents(
clientEntryFiles: Record<string, string>,
componentImportMap: Map<string, string[]>,
) {
const serverComponentImports = new Map<string, string[]>()
const clientComponentImports = new Map<string, string[]>()
const clientComponentIds = Object.values(clientEntryFiles)
for (const [key, value] of componentImportMap.entries()) {
if (clientComponentIds.includes(key)) {
clientComponentImports.set(key, value)
} else {
serverComponentImports.set(key, value)
}
}
return { serverComponentImports, clientComponentImports }
}

export function generateServerComponentClientComponentMapping(
serverComponentImports: Map<string, string[]>,
clientComponentImports: Map<string, string[]>,
) {
const serverComponentClientImportIds = new Map<string, string[]>()
const gatherClientImports = (
id: string,
clientImports: Set<string>,
): void => {
const imports = clientComponentImports.get(id) ?? []
for (const importId of imports) {
if (!clientImports.has(importId)) {
clientImports.add(importId)
gatherClientImports(importId, clientImports)
}
}
}
for (const serverComponentId of serverComponentImports.keys()) {
const clientImports = new Set<string>()
const topLevelClientImports =
serverComponentImports.get(serverComponentId) ?? []
for (const importId of topLevelClientImports) {
if (clientComponentImports.has(importId)) {
clientImports.add(importId)
}
gatherClientImports(importId, clientImports)
}
serverComponentClientImportIds.set(
serverComponentId,
Array.from(clientImports),
)
}
return serverComponentClientImportIds
}

export function rscCssPreinitPlugin(
clientEntryFiles: Record<string, string>,
componentImportMap: Map<string, string[]>,
): Plugin {
const webSrc = getPaths().web.src

// This plugin is build only and we expect the client build manifest to be
// available at this point. We use it to find the correct css assets names
const clientBuildManifest = JSON.parse(
fs.readFileSync(
path.join(getPaths().web.distClient, 'client-build-manifest.json'),
'utf-8',
),
)

// We generate a mapping of all the css assets that a client build manifest
// entry contains (looking deep into the tree of entries)
const clientBuildManifestCss = generateCssMapping(clientBuildManifest)

// We filter to have individual maps for server components and client
// components
const { serverComponentImports, clientComponentImports } =
splitClientAndServerComponents(clientEntryFiles, componentImportMap)

// We generate a mapping of server components to all the client components
// that they import (directly or indirectly)
const serverComponentClientImportIds =
generateServerComponentClientComponentMapping(
serverComponentImports,
clientComponentImports,
)

return {
name: 'rsc-css-preinit',
apply: 'build',
transform: async function (code, id) {
// We only care about code in the project itself
if (!id.startsWith(webSrc)) {
return null
}

// We only care about server components
if (!serverComponentImports.has(id)) {
return null
}

// Get the client components this server component imports (directly or
// indirectly)
const clientImportIds = serverComponentClientImportIds.get(id) ?? []
if (clientImportIds.length === 0) {
return null
}

// Extract all the CSS asset names from all the client components that
// this server component imports
const assetNames = new Set<string>()
for (const clientImportId of clientImportIds) {
const shortName = path.basename(clientImportId)
const longName = clientImportId.substring(webSrc.length + 1)
const entries =
clientBuildManifestCss.get(shortName) ??
clientBuildManifestCss.get(longName) ??
[]
for (const entry of entries) {
assetNames.add(entry)
}
}

if (assetNames.size === 0) {
return null
}

// Analyse the AST to get all the components that we have to insert preinit
// calls into
const ext = path.extname(id)

const plugins = []
if (ext === '.jsx') {
plugins.push('jsx')
}
const ast = babelParse(code, {
sourceType: 'unambiguous',
// @ts-expect-error TODO fix me
plugins,
})

// Gather a list of the names of exported components
const namedExportNames: string[] = []
traverse(ast, {
ExportDefaultDeclaration(path: NodePath<t.ExportDefaultDeclaration>) {
const declaration = path.node.declaration
if (t.isIdentifier(declaration)) {
namedExportNames.push(declaration.name)
}
},
})

// Insert: import { preinit } from 'react-dom'
ast.program.body.unshift(
t.importDeclaration(
[t.importSpecifier(t.identifier('preinit'), t.identifier('preinit'))],
t.stringLiteral('react-dom'),
),
)

// TODO: Confirm this is a react component by looking for `jsxs` in the AST
// For each named export, insert a preinit call for each asset that it will
// eventually need for all it's child client components
traverse(ast, {
VariableDeclaration(path: NodePath<t.VariableDeclaration>) {
const declaration = path.node.declarations[0]
if (
t.isVariableDeclarator(declaration) &&
t.isIdentifier(declaration.id) &&
namedExportNames.includes(declaration.id.name)
) {
if (t.isArrowFunctionExpression(declaration.init)) {
const body = declaration.init.body
if (t.isBlockStatement(body)) {
for (const assetName of assetNames) {
body.body.unshift(
t.expressionStatement(
t.callExpression(t.identifier('preinit'), [
t.stringLiteral(assetName),
t.objectExpression([
t.objectProperty(
t.identifier('as'),
t.stringLiteral('style'),
),
]),
]),
),
)
}
}
}
}
},
})

// Just for debugging/verbose logging
console.log(
'css-preinit:',
id.substring(webSrc.length + 1),
'x' + assetNames.size,
'(' + Array.from(assetNames).join(', ') + ')',
)

return generate(ast).code
},
}
}
11 changes: 10 additions & 1 deletion packages/vite/src/rsc/rscBuildAnalyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export async function rscBuildAnalyze() {
const rwPaths = getPaths()
const clientEntryFileSet = new Set<string>()
const serverEntryFileSet = new Set<string>()
const componentImportMap = new Map<string, string[]>()

if (!rwPaths.web.entries) {
throw new Error('RSC entries file not found')
Expand All @@ -45,6 +46,10 @@ export async function rscBuildAnalyze() {
rscAnalyzePlugin(
(id) => clientEntryFileSet.add(id),
(id) => serverEntryFileSet.add(id),
(id, imports) => {
const existingImports = componentImportMap.get(id) ?? []
componentImportMap.set(id, [...existingImports, ...imports])
},
),
],
ssr: {
Expand Down Expand Up @@ -96,5 +101,9 @@ export async function rscBuildAnalyze() {
console.log('clientEntryFiles', clientEntryFiles)
console.log('serverEntryFiles', serverEntryFiles)

return { clientEntryFiles, serverEntryFiles }
return {
clientEntryFiles,
serverEntryFiles,
componentImportMap,
}
}
3 changes: 3 additions & 0 deletions packages/vite/src/rsc/rscBuildForServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { build as viteBuild } from 'vite'
import { getPaths } from '@redwoodjs/project-config'

import { onWarn } from '../lib/onWarn.js'
import { rscCssPreinitPlugin } from '../plugins/vite-plugin-rsc-css-preinit.js'
import { rscTransformUseClientPlugin } from '../plugins/vite-plugin-rsc-transform-client.js'
import { rscTransformUseServerPlugin } from '../plugins/vite-plugin-rsc-transform-server.js'

Expand All @@ -17,6 +18,7 @@ export async function rscBuildForServer(
clientEntryFiles: Record<string, string>,
serverEntryFiles: Record<string, string>,
customModules: Record<string, string>,
componentImportMap: Map<string, string[]>,
) {
console.log('\n')
console.log('3. rscBuildForServer')
Expand Down Expand Up @@ -65,6 +67,7 @@ export async function rscBuildForServer(
// (It does other things as well, but that's why it needs clientEntryFiles)
rscTransformUseClientPlugin(clientEntryFiles),
rscTransformUseServerPlugin(),
rscCssPreinitPlugin(clientEntryFiles, componentImportMap),
],
build: {
ssr: true,
Expand Down
3 changes: 3 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8859,6 +8859,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "@redwoodjs/vite@workspace:packages/vite"
dependencies:
"@babel/generator": "npm:7.23.6"
"@babel/parser": "npm:^7.22.16"
"@babel/traverse": "npm:^7.22.20"
"@redwoodjs/babel-config": "workspace:*"
"@redwoodjs/internal": "workspace:*"
"@redwoodjs/project-config": "workspace:*"
Expand Down

0 comments on commit 2dccca8

Please sign in to comment.