Skip to content

Commit

Permalink
feat: prevent fouc in development (#12)
Browse files Browse the repository at this point in the history
* feat: prevent fouc in development

* fix: test failing on windows
  • Loading branch information
Julien-R44 authored Mar 25, 2024
1 parent b8dc984 commit 15bef87
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 81 deletions.
2 changes: 1 addition & 1 deletion bin/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { snapshot } from '@japa/snapshot'
import { fileSystem } from '@japa/file-system'
import { processCLIArgs, configure, run } from '@japa/runner'

import { BASE_URL } from '../tests_helpers/index.js'
import { BASE_URL } from '../tests/backend/helpers.js'

/*
|--------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export const edgePluginVite: (vite: Vite) => PluginFn<undefined> = (vite) => {
: `generateEntryPointsTags(${entrypoints})`

buffer.outputExpression(
`state.vite.${methodCall}.join('\\n')`,
`(await state.vite.${methodCall}).join('\\n')`,
token.filename,
token.loc.start.line,
false
Expand Down
116 changes: 106 additions & 10 deletions src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,23 @@
* file that was distributed with this source code.
*/

import { join } from 'node:path'
import { readFileSync } from 'node:fs'
import { slash } from '@poppinss/utils'
import type { ViteRuntime } from 'vite/runtime'
import type { InlineConfig, MainThreadRuntimeOptions, Manifest, ViteDevServer } from 'vite'
import type {
InlineConfig,
MainThreadRuntimeOptions,
Manifest,
ModuleNode,
ViteDevServer,
} from 'vite'

import { makeAttributes, uniqBy } from './utils.js'
import type { AdonisViteElement, SetAttributes, ViteOptions } from './types.js'

const styleFileRegex = /\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\?)/

/**
* Vite class exposes the APIs to generate tags and URLs for
* assets processed using vite.
Expand Down Expand Up @@ -63,7 +73,7 @@ export class Vite {
/**
* Returns the script needed for the HMR working with Vite
*/
#getViteHmrScript(attributes?: Record<string, any>): AdonisViteElement | null {
#getViteHmrScript(attributes?: Record<string, any>) {
return this.#generateElement({
tag: 'script',
attributes: {
Expand All @@ -79,7 +89,17 @@ export class Vite {
* Check if the given path is a CSS path
*/
#isCssPath(path: string) {
return path.match(/\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/) !== null
return path.match(styleFileRegex) !== null
}

/**
* If the module is a style module
*/
#isStyleModule(mod: ModuleNode) {
if (this.#isCssPath(mod.url) || (mod.id && /\?vue&type=style/.test(mod.id))) {
return true
}
return false
}

/**
Expand Down Expand Up @@ -142,20 +162,96 @@ export class Vite {
return this.#makeScriptTag(asset, url, attributes)
}

/**
* Collect CSS files from the module graph recursively
*/
#collectCss(
mod: ModuleNode,
styleUrls: Set<string>,
visitedModules: Set<string>,
importer?: ModuleNode
): void {
if (!mod.url) return

/**
* Prevent visiting the same module twice
*/
if (visitedModules.has(mod.url)) return
visitedModules.add(mod.url)

if (this.#isStyleModule(mod) && (!importer || !this.#isStyleModule(importer))) {
if (mod.url.startsWith('/')) {
styleUrls.add(mod.url)
} else if (mod.url.startsWith('\0')) {
// virtual modules are prefixed with \0
styleUrls.add(`/@id/__x00__${mod.url.substring(1)}`)
} else {
styleUrls.add(`/@id/${mod.url}`)
}
}

mod.importedModules.forEach((dep) => this.#collectCss(dep, styleUrls, visitedModules, mod))
}

/**
* Generate style and script tags for the given entrypoints
* Also adds the @vite/client script
*/
#generateEntryPointsTagsForDevMode(
async #generateEntryPointsTagsForDevMode(
entryPoints: string[],
attributes?: Record<string, any>
): AdonisViteElement[] {
const viteHmr = this.#getViteHmrScript(attributes)
): Promise<AdonisViteElement[]> {
const server = this.getDevServer()!
const runtime = await this.createRuntime()

const tags = entryPoints.map((entrypoint) => this.#generateTag(entrypoint, attributes))
const jsEntrypoints = entryPoints.filter((entrypoint) => !this.#isCssPath(entrypoint))

/**
* If the module graph is empty, that means we didn't execute the entrypoint
* yet : we just started the AdonisJS dev server.
* So let's execute the entrypoints to populate the module graph
*/
if (server?.moduleGraph.idToModuleMap.size === 0) {
await Promise.allSettled(
jsEntrypoints.map((entrypoint) => runtime.executeEntrypoint(entrypoint))
).catch(console.error)
}

/**
* We need to collect the CSS files imported by the entrypoints
* Otherwise, we gonna have a FOUC each time we full reload the page
*/
const preloadUrls = new Set<string>()
const visitedModules = new Set<string>()
const cssTagsElement = new Set<AdonisViteElement>()

/**
* Let's search for the CSS files by browsing the module graph
* generated by Vite.
*/
for (const entryPoint of jsEntrypoints) {
const filePath = join(server.config.root, entryPoint)
const entryMod = server.moduleGraph.getModuleById(slash(filePath))
if (entryMod) this.#collectCss(entryMod, preloadUrls, visitedModules)
}

const result = viteHmr ? [viteHmr].concat(tags) : tags
/**
* Once we have the CSS files, generate associated tags
* that will be injected into the HTML
*/
const elements = Array.from(preloadUrls).map((href) =>
this.#generateElement({
tag: 'link',
attributes: { rel: 'stylesheet', as: 'style', href: href },
})
)
elements.forEach((element) => cssTagsElement.add(element))

const viteHmr = this.#getViteHmrScript(attributes)
const result = [...cssTagsElement, viteHmr].concat(tags)

return result
return result.sort((tag) => (tag.tag === 'link' ? -1 : 1))
}

/**
Expand Down Expand Up @@ -264,10 +360,10 @@ export class Vite {
/**
* Generate tags for the entry points
*/
generateEntryPointsTags(
async generateEntryPointsTags(
entryPoints: string[] | string,
attributes?: Record<string, any>
): AdonisViteElement[] {
): Promise<AdonisViteElement[]> {
entryPoints = Array.isArray(entryPoints) ? entryPoints : [entryPoints]

if (this.isViteRunning) {
Expand Down
13 changes: 7 additions & 6 deletions tests/backend/edge_plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import { Edge } from 'edge.js'
import { test } from '@japa/runner'

import { Vite } from '../../src/vite.js'
import { createVite } from './helpers.js'
import { defineConfig } from '../../src/define_config.js'
import { edgePluginVite } from '../../src/plugins/edge.js'

test.group('Edge plugin vite', () => {
test('generate asset path within edge template', async ({ assert }) => {
const edge = Edge.create()
const vite = new Vite(true, defineConfig({}))
const vite = await createVite(defineConfig({}))
edge.use(edgePluginVite(vite))

const html = await edge.renderRaw(`{{ asset('foo.png') }}`)
Expand All @@ -26,7 +27,7 @@ test.group('Edge plugin vite', () => {

test('share vite instance with edge', async ({ assert }) => {
const edge = Edge.create()
const vite = new Vite(true, defineConfig({}))
const vite = await createVite(defineConfig({}))
edge.use(edgePluginVite(vite))

const html = await edge.renderRaw(`{{ vite.assetPath('foo.png') }}`)
Expand All @@ -35,7 +36,7 @@ test.group('Edge plugin vite', () => {

test('output reactHMRScript', async ({ assert }) => {
const edge = Edge.create()
const vite = new Vite(true, defineConfig({}))
const vite = await createVite(defineConfig({}))
edge.use(edgePluginVite(vite))

const html = await edge.renderRaw(`@viteReactRefresh()`)
Expand All @@ -52,7 +53,7 @@ test.group('Edge plugin vite', () => {

test('pass custom attributes to reactHMRScript', async ({ assert }) => {
const edge = Edge.create()
const vite = new Vite(true, defineConfig({}))
const vite = await createVite(defineConfig({}))
edge.use(edgePluginVite(vite))

const html = await edge.renderRaw(`@viteReactRefresh({ nonce: 'foo' })`)
Expand All @@ -78,7 +79,7 @@ test.group('Edge plugin vite', () => {

test('output entrypoint tags', async ({ assert }) => {
const edge = Edge.create()
const vite = new Vite(true, defineConfig({}))
const vite = await createVite(defineConfig({}))
edge.use(edgePluginVite(vite))

const html = await edge.renderRaw(`@vite(['resources/js/app.js'])`)
Expand All @@ -90,7 +91,7 @@ test.group('Edge plugin vite', () => {

test('output entrypoint tags with custom attributes', async ({ assert }) => {
const edge = Edge.create()
const vite = new Vite(true, defineConfig({}))
const vite = await createVite(defineConfig({}))
edge.use(edgePluginVite(vite))

const html = await edge.renderRaw(`@vite(['resources/js/app.js'], { nonce: 'foo' })`)
Expand Down
46 changes: 46 additions & 0 deletions tests/backend/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* @adonisjs/vite
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { getActiveTest } from '@japa/runner'

import { Vite } from '../../index.js'
import { ViteOptions } from '../../src/types.js'
import { InlineConfig } from 'vite'

export const BASE_URL = new URL('./../__app/', import.meta.url)

/**
* Create an instance of AdonisJS Vite class, run the dev server
* and auto close it when the test ends
*/
export async function createVite(config: ViteOptions, viteConfig: InlineConfig = {}) {
const test = getActiveTest()
if (!test) {
throw new Error('Cannot create vite instance outside of a test')
}

/**
* Create a dummy file to ensure the root directory exists
* otherwise Vite will throw an error
*/
await test.context.fs.create('dummy.txt', 'dummy')

const vite = new Vite(true, config)

await vite.createDevServer({
logLevel: 'silent',
clearScreen: false,
root: test.context.fs.basePath,
...viteConfig,
})

test.cleanup(() => vite.stopDevServer())

return vite
}
Loading

0 comments on commit 15bef87

Please sign in to comment.