diff --git a/.changeset/proud-adults-collect.md b/.changeset/proud-adults-collect.md new file mode 100644 index 00000000000..f5d9c4d5cc4 --- /dev/null +++ b/.changeset/proud-adults-collect.md @@ -0,0 +1,5 @@ +--- +'@builder.io/qwik-city': patch +--- + +FEAT: added `verbose` logger to QwikCity Service Worker diff --git a/e2e/buffering/.gitignore b/e2e/buffering/.gitignore new file mode 100644 index 00000000000..66cb7c2bafd --- /dev/null +++ b/e2e/buffering/.gitignore @@ -0,0 +1,5 @@ +playwright-report +dist +logs +server +tmp \ No newline at end of file diff --git a/e2e/buffering/package.json b/e2e/buffering/package.json new file mode 100644 index 00000000000..af6b53e8322 --- /dev/null +++ b/e2e/buffering/package.json @@ -0,0 +1,27 @@ +{ + "name": "qwik-buffering-test-app", + "description": "Qwik buffering test app", + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "private": true, + "type": "module", + "scripts": { + "build": "qwik build", + "build.client": "vite build", + "build.preview": "vite build --ssr src/entry.preview.tsx", + "build.types": "tsc --incremental --noEmit", + "deploy": "vercel deploy", + "dev": "vite --mode ssr", + "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force", + "fmt": "prettier --write .", + "fmt.check": "prettier --check .", + "lint": "eslint \"src/**/*.ts*\"", + "preview": "qwik build preview && vite preview --open", + "start": "vite --open --mode ssr", + "qwik": "qwik", + "test": "playwright test", + "test.ui": "playwright test --ui", + "test.debug": "playwright test --debug" + } +} diff --git a/e2e/buffering/playwright.config.ts b/e2e/buffering/playwright.config.ts new file mode 100644 index 00000000000..64e6f64a50e --- /dev/null +++ b/e2e/buffering/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** See https://playwright.dev/docs/test-configuration. */ +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + baseURL: 'http://localhost:4173', + // trace: 'on-first-retry', + // screenshot: 'only-on-failure', + + // Increase timeouts for service worker operations + actionTimeout: 10000, + navigationTimeout: 10000, + }, + + // Increase global timeout for service worker tests + timeout: 30000, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], + + webServer: { + command: 'npm run preview', + port: 4173, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/e2e/buffering/src/components/click-me/click-me.tsx b/e2e/buffering/src/components/click-me/click-me.tsx new file mode 100644 index 00000000000..1141e7d5e9c --- /dev/null +++ b/e2e/buffering/src/components/click-me/click-me.tsx @@ -0,0 +1,18 @@ +import { component$, useSignal } from '@builder.io/qwik'; + +// We need to extract the component to see the bug on 1.5.7 +export default component$(() => { + const isOpenSig = useSignal(false); + return ( + <> + + {isOpenSig.value &&
Hi 👋
} + + ); +}); diff --git a/e2e/buffering/src/components/router-head/router-head.tsx b/e2e/buffering/src/components/router-head/router-head.tsx new file mode 100644 index 00000000000..44ee2fd5408 --- /dev/null +++ b/e2e/buffering/src/components/router-head/router-head.tsx @@ -0,0 +1,24 @@ +import { component$ } from '@builder.io/qwik'; +import { useDocumentHead, useLocation } from '@builder.io/qwik-city'; + +export const RouterHead = component$(() => { + const head = useDocumentHead(); + const loc = useLocation(); + + return ( + <> + {head.title} + + + + + {head.meta.map((m) => ( + + ))} + + {head.links.map((l) => ( + + ))} + + ); +}); diff --git a/e2e/buffering/src/entry.dev.tsx b/e2e/buffering/src/entry.dev.tsx new file mode 100644 index 00000000000..4f8f58fa5c3 --- /dev/null +++ b/e2e/buffering/src/entry.dev.tsx @@ -0,0 +1,17 @@ +/* + * WHAT IS THIS FILE? + * + * Development entry point using only client-side modules: + * - Do not use this mode in production! + * - No SSR + * - No portion of the application is pre-rendered on the server. + * - All of the application is running eagerly in the browser. + * - More code is transferred to the browser than in SSR mode. + * - Optimizer/Serialization/Deserialization code is not exercised! + */ +import { render, type RenderOptions } from '@builder.io/qwik'; +import Root from './root'; + +export default function (opts: RenderOptions) { + return render(document, , opts); +} diff --git a/e2e/buffering/src/entry.preview.tsx b/e2e/buffering/src/entry.preview.tsx new file mode 100644 index 00000000000..3882063a3f8 --- /dev/null +++ b/e2e/buffering/src/entry.preview.tsx @@ -0,0 +1,19 @@ +/* + * WHAT IS THIS FILE? + * + * It's the bundle entry point for `npm run preview`. + * That is, serving your app built in production mode. + * + * Feel free to modify this file, but don't remove it! + * + * Learn more about Vite's preview command: + * - https://vitejs.dev/config/preview-options.html#preview-options + * + */ +import { createQwikCity } from '@builder.io/qwik-city/middleware/node'; +import qwikCityPlan from '@qwik-city-plan'; +// make sure qwikCityPlan is imported before entry +import render from './entry.ssr'; + +/** The default export is the QwikCity adapter used by Vite preview. */ +export default createQwikCity({ render, qwikCityPlan }); diff --git a/e2e/buffering/src/entry.ssr.tsx b/e2e/buffering/src/entry.ssr.tsx new file mode 100644 index 00000000000..cdd6fbe6e59 --- /dev/null +++ b/e2e/buffering/src/entry.ssr.tsx @@ -0,0 +1,35 @@ +/** + * WHAT IS THIS FILE? + * + * SSR entry point, in all cases the application is rendered outside the browser, this entry point + * will be the common one. + * + * - Server (express, cloudflare...) + * - Npm run start + * - Npm run preview + * - Npm run build + */ +import { renderToStream, type RenderToStreamOptions } from '@builder.io/qwik/server'; +import { manifest } from '@qwik-client-manifest'; +import Root from './root'; + +export default function (opts: RenderToStreamOptions) { + return renderToStream(, { + manifest, + ...opts, + // Use container attributes to set attributes on the html tag. + containerAttributes: { + lang: 'en-us', + ...opts.containerAttributes, + }, + // prefetchStrategy: { + // implementation: { + // linkInsert: "html-append", + // linkRel: "modulepreload", + // }, + // }, + serverData: { + ...opts.serverData, + }, + }); +} diff --git a/e2e/buffering/src/root.tsx b/e2e/buffering/src/root.tsx new file mode 100644 index 00000000000..6d9d26534af --- /dev/null +++ b/e2e/buffering/src/root.tsx @@ -0,0 +1,27 @@ +import { component$ } from '@builder.io/qwik'; +import { QwikCityProvider, RouterOutlet, ServiceWorkerRegister } from '@builder.io/qwik-city'; +import { RouterHead } from './components/router-head/router-head'; + +export default component$(() => { + /** + * The root of a QwikCity site always start with the component, immediately + * followed by the document's and . + * + * Don't remove the `` and `` elements. + */ + + return ( + + + + + {/* + */} + + + + + + + ); +}); diff --git a/e2e/buffering/src/routes/index.tsx b/e2e/buffering/src/routes/index.tsx new file mode 100644 index 00000000000..d3585ff0a69 --- /dev/null +++ b/e2e/buffering/src/routes/index.tsx @@ -0,0 +1,26 @@ +import { component$ } from '@builder.io/qwik'; +import { type DocumentHead } from '@builder.io/qwik-city'; +import ClickMe from '~/components/click-me/click-me'; + +export default component$(() => { + return ( + <> + go to profile +
+

Home page

+
+ {/* We need to extract the component to see the bug on 1.5.7 */} + + + ); +}); + +export const head: DocumentHead = { + title: 'Welcome to Qwik', + meta: [ + { + name: 'description', + content: 'Qwik site description', + }, + ], +}; diff --git a/e2e/buffering/src/routes/layout.tsx b/e2e/buffering/src/routes/layout.tsx new file mode 100644 index 00000000000..d0e76e6d853 --- /dev/null +++ b/e2e/buffering/src/routes/layout.tsx @@ -0,0 +1,5 @@ +import { component$, Slot } from '@builder.io/qwik'; + +export default component$(() => { + return ; +}); diff --git a/e2e/buffering/src/routes/profile/index.tsx b/e2e/buffering/src/routes/profile/index.tsx new file mode 100644 index 00000000000..1f6f835a4b2 --- /dev/null +++ b/e2e/buffering/src/routes/profile/index.tsx @@ -0,0 +1,11 @@ +import { component$ } from '@builder.io/qwik'; + +export default component$(() => { + return ( + <> + go to home +
+

Profile page 🙂

+ + ); +}); diff --git a/e2e/buffering/src/routes/service-worker.ts b/e2e/buffering/src/routes/service-worker.ts new file mode 100644 index 00000000000..fb1bb3fd75e --- /dev/null +++ b/e2e/buffering/src/routes/service-worker.ts @@ -0,0 +1,18 @@ +/* + * WHAT IS THIS FILE? + * + * The service-worker.ts file is used to have state of the art prefetching. + * https://qwik.dev/qwikcity/prefetching/overview/ + * + * Qwik uses a service worker to speed up your site and reduce latency, ie, not used in the traditional way of offline. + * You can also use this file to add more functionality that runs in the service worker. + */ +import { setupServiceWorker } from '@builder.io/qwik-city/service-worker'; + +setupServiceWorker(); + +addEventListener('install', () => self.skipWaiting()); + +addEventListener('activate', () => self.clients.claim()); + +declare const self: ServiceWorkerGlobalScope; diff --git a/e2e/buffering/tests/basic.spec.ts b/e2e/buffering/tests/basic.spec.ts new file mode 100644 index 00000000000..4c36deaa0bd --- /dev/null +++ b/e2e/buffering/tests/basic.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Bufferring', () => { + test.beforeEach(async ({ page }) => { + // Clear cache before each test + await page.goto('/'); + await page.evaluate(async () => { + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map((name) => caches.delete(name))); + }); + }); + + test('should buffer all the bundles', async ({ page }) => { + await page.goto('/'); + + await page.waitForFunction(() => navigator.serviceWorker?.controller); + + const requests = new Set(); + + page.route('**/*.js', (route) => { + requests.add(route.request().url()); + console.log('route', route.request().url()); + route.continue(); + }); + + // Interact with the page + const button = page.getByRole('button', { name: /click me/i }); + await button.click(); + + console.log('requests', requests); + const jsBundles = Array.from(requests).filter((url) => url.includes('.js')); + console.log(jsBundles); + + const cachedBundles = await page.evaluate(async (bundles) => { + const cache = await caches.open('QwikBuild'); + const keys = await cache.keys(); + console.log('keys', keys); + const cachedBundles = keys.map((key) => key.url); + console.log('cachedBundles', cachedBundles); + return cachedBundles; + }, jsBundles); + + await expect(page.getByTestId('hi')).toBeVisible(); + expect(cachedBundles).toEqual(jsBundles); + }); +}); diff --git a/e2e/buffering/tsconfig.json b/e2e/buffering/tsconfig.json new file mode 100644 index 00000000000..634e8d388b9 --- /dev/null +++ b/e2e/buffering/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "allowJs": true, + "target": "ES2017", + "module": "ES2022", + "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "@builder.io/qwik", + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "Bundler", + "esModuleInterop": true, + "skipLibCheck": true, + "incremental": true, + "isolatedModules": true, + "outDir": "tmp", + "noEmit": true, + "paths": { + "~/*": ["./src/*"] + } + }, + + "include": ["src", "./*.d.ts", "./*.config.ts"] +} diff --git a/e2e/buffering/vite.config.ts b/e2e/buffering/vite.config.ts new file mode 100644 index 00000000000..33041ccc0ca --- /dev/null +++ b/e2e/buffering/vite.config.ts @@ -0,0 +1,109 @@ +/** + * This is the base config for vite. When building, the adapter config is used which loads this file + * and extends it. + */ +import { qwikCity } from '@builder.io/qwik-city/vite'; +import { qwikVite } from '@builder.io/qwik/optimizer'; +import { defineConfig, type UserConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import pkg from './package.json'; + +type PkgDep = Record; +const { dependencies = {}, devDependencies = {} } = pkg as any as { + dependencies: PkgDep; + devDependencies: PkgDep; + [key: string]: unknown; +}; +errorOnDuplicatesPkgDeps(devDependencies, dependencies); + +/** + * Note that Vite normally starts from `index.html` but the qwikCity plugin makes start at + * `src/entry.ssr.tsx` instead. + */ +export default defineConfig(({ command, mode }): UserConfig => { + return { + plugins: [ + qwikCity(), + qwikVite(), + tsconfigPaths({ + root: '.', + }), + ], + // This tells Vite which dependencies to pre-build in dev mode. + optimizeDeps: { + // Put problematic deps that break bundling here, mostly those with binaries. + // For example ['better-sqlite3'] if you use that in server functions. + exclude: [], + }, + + /** + * This is an advanced setting. It improves the bundling of your server code. To use it, make + * sure you understand when your consumed packages are dependencies or dev dependencies. + * (otherwise things will break in production) + */ + // ssr: + // command === "build" && mode === "production" + // ? { + // // All dev dependencies should be bundled in the server build + // noExternal: Object.keys(devDependencies), + // // Anything marked as a dependency will not be bundled + // // These should only be production binary deps (including deps of deps), CLI deps, and their module graph + // // If a dep-of-dep needs to be external, add it here + // // For example, if something uses `bcrypt` but you don't have it as a dep, you can write + // // external: [...Object.keys(dependencies), 'bcrypt'] + // external: Object.keys(dependencies), + // } + // : undefined, + + server: { + headers: { + // Don't cache the server response in dev mode + 'Cache-Control': 'public, max-age=0', + }, + }, + preview: { + headers: { + // Do cache the server response in preview (non-adapter production build) + 'Cache-Control': 'public, max-age=360', + }, + }, + }; +}); + +// *** utils *** + +/** + * Function to identify duplicate dependencies and throw an error + * + * @param {Object} devDependencies - List of development dependencies + * @param {Object} dependencies - List of production dependencies + */ +function errorOnDuplicatesPkgDeps(devDependencies: PkgDep, dependencies: PkgDep) { + let msg = ''; + // Create an array 'duplicateDeps' by filtering devDependencies. + // If a dependency also exists in dependencies, it is considered a duplicate. + const duplicateDeps = Object.keys(devDependencies).filter((dep) => dependencies[dep]); + + // include any known qwik packages + const qwikPkg = Object.keys(dependencies).filter((value) => /qwik/i.test(value)); + + // any errors for missing "qwik-city-plan" + // [PLUGIN_ERROR]: Invalid module "@qwik-city-plan" is not a valid package + msg = `Move qwik packages ${qwikPkg.join(', ')} to devDependencies`; + + if (qwikPkg.length > 0) { + throw new Error(msg); + } + + // Format the error message with the duplicates list. + // The `join` function is used to represent the elements of the 'duplicateDeps' array as a comma-separated string. + msg = ` + Warning: The dependency "${duplicateDeps.join(', ')}" is listed in both "devDependencies" and "dependencies". + Please move the duplicated dependencies to "devDependencies" only and remove it from "dependencies" + `; + + // Throw an error with the constructed message. + if (duplicateDeps.length > 0) { + throw new Error(msg); + } +} diff --git a/package.json b/package.json index e6e520507a7..fad0e0f5c66 100644 --- a/package.json +++ b/package.json @@ -221,7 +221,7 @@ "build.validate": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --build --api --eslint --qwikcity --platform-binding --wasm --validate", "build.vite": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --build --api --qwikcity --eslint --platform-binding-wasm-copy", "build.wasm": "tsx --require ./scripts/runBefore.ts scripts/index.ts --wasm", - "build.watch": "tsx --require ./scripts/runBefore.ts scripts/index.ts --build --qwikcity --watch --dev --platform-binding", + "build.watch": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --build --qwikcity --watch --dev --platform-binding", "change": "changeset", "cli": "pnpm build.cli && node packages/create-qwik/create-qwik.cjs && tsx --require ./scripts/runBefore.ts scripts/validate-cli.ts --copy-local-qwik-dist", "cli.qwik": "pnpm build.cli && node packages/qwik/qwik-cli.cjs", diff --git a/packages/docs/src/routes/api/qwik-city/api.json b/packages/docs/src/routes/api/qwik-city/api.json index 6c42d66b5a9..ebdc0a671cc 100644 --- a/packages/docs/src/routes/api/qwik-city/api.json +++ b/packages/docs/src/routes/api/qwik-city/api.json @@ -782,7 +782,7 @@ } ], "kind": "Function", - "content": "```typescript\nServiceWorkerRegister: (props: {\n nonce?: string;\n}) => import(\"@builder.io/qwik\").JSXNode<\"script\">\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nprops\n\n\n\n\n{ nonce?: string; }\n\n\n\n\n\n
\n**Returns:**\n\nimport(\"@builder.io/qwik\").JSXNode<\"script\">", + "content": "```typescript\nServiceWorkerRegister: (props: {\n nonce?: string;\n verbose?: boolean;\n}) => import(\"@builder.io/qwik\").JSXNode<\"script\">\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nprops\n\n\n\n\n{ nonce?: string; verbose?: boolean; }\n\n\n\n\n\n
\n**Returns:**\n\nimport(\"@builder.io/qwik\").JSXNode<\"script\">", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/sw-component.tsx", "mdFile": "qwik-city.serviceworkerregister.md" }, diff --git a/packages/docs/src/routes/api/qwik-city/index.md b/packages/docs/src/routes/api/qwik-city/index.md index 51792d84a2a..d4a7344ae17 100644 --- a/packages/docs/src/routes/api/qwik-city/index.md +++ b/packages/docs/src/routes/api/qwik-city/index.md @@ -2270,7 +2270,7 @@ export type ServerQRL = QRL< ## ServiceWorkerRegister ```typescript -ServiceWorkerRegister: (props: { nonce?: string }) => +ServiceWorkerRegister: (props: { nonce?: string; verbose?: boolean }) => import("@builder.io/qwik").JSXNode<"script">; ``` @@ -2293,7 +2293,7 @@ props -{ nonce?: string; } +{ nonce?: string; verbose?: boolean; } diff --git a/packages/docs/src/routes/api/qwik-optimizer/api.json b/packages/docs/src/routes/api/qwik-optimizer/api.json index e12f43ae489..590006cf755 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/api.json +++ b/packages/docs/src/routes/api/qwik-optimizer/api.json @@ -19,6 +19,20 @@ "content": "```typescript\nbasename(path: string, ext?: string): string;\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npath\n\n\n\n\nstring\n\n\n\n\n\n
\n\next\n\n\n\n\nstring\n\n\n\n\n_(Optional)_\n\n\n
\n**Returns:**\n\nstring", "mdFile": "qwik.path.basename.md" }, + { + "name": "BundleGraphModifier", + "id": "bundlegraphmodifier", + "hierarchy": [ + { + "name": "BundleGraphModifier", + "id": "bundlegraphmodifier" + } + ], + "kind": "TypeAlias", + "content": "A function that creates a modified version of the bundle graph. Used to inject routes and their dependencies into the bundle graph.\n\n\n```typescript\nexport type BundleGraphModifier = (graph: QwikBundleGraph, manifest: QwikManifest) => QwikBundleGraph;\n```\n**References:** [QwikBundleGraph](#qwikbundlegraph), [QwikManifest](#qwikmanifest)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/optimizer/src/plugins/bundle-graph.ts", + "mdFile": "qwik.bundlegraphmodifier.md" + }, { "name": "ComponentEntryStrategy", "id": "componententrystrategy", @@ -410,6 +424,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/optimizer/src/types.ts", "mdFile": "qwik.qwikbundle.md" }, + { + "name": "QwikBundleGraph", + "id": "qwikbundlegraph", + "hierarchy": [ + { + "name": "QwikBundleGraph", + "id": "qwikbundlegraph" + } + ], + "kind": "TypeAlias", + "content": "Bundle graph.\n\nFormat: \\[ 'bundle-a.js', 3, 5 // Depends on 'bundle-b.js' and 'bundle-c.js' 'bundle-b.js', 5, // Depends on 'bundle-c.js' 'bundle-c.js', \\]\n\n\n```typescript\nexport type QwikBundleGraph = Array;\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/optimizer/src/types.ts", + "mdFile": "qwik.qwikbundlegraph.md" + }, { "name": "QwikManifest", "id": "qwikmanifest", @@ -518,7 +546,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface QwikVitePluginApi \n```\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[getAssetsDir](#)\n\n\n\n\n\n\n\n() => string \\| undefined\n\n\n\n\n\n
\n\n[getClientOutDir](#)\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\n[getClientPublicOutDir](#)\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\n[getInsightsManifest](#)\n\n\n\n\n\n\n\n(clientOutDir?: string \\| null) => Promise<[InsightManifest](#insightmanifest) \\| null>\n\n\n\n\n\n
\n\n[getManifest](#)\n\n\n\n\n\n\n\n() => [QwikManifest](#qwikmanifest) \\| null\n\n\n\n\n\n
\n\n[getOptimizer](#)\n\n\n\n\n\n\n\n() => [Optimizer](#optimizer) \\| null\n\n\n\n\n\n
\n\n[getOptions](#)\n\n\n\n\n\n\n\n() => NormalizedQwikPluginOptions\n\n\n\n\n\n
\n\n[getRootDir](#)\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
", + "content": "```typescript\nexport interface QwikVitePluginApi \n```\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[getAssetsDir](#)\n\n\n\n\n\n\n\n() => string \\| undefined\n\n\n\n\n\n
\n\n[getClientOutDir](#)\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\n[getClientPublicOutDir](#)\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\n[getInsightsManifest](#)\n\n\n\n\n\n\n\n(clientOutDir?: string \\| null) => Promise<[InsightManifest](#insightmanifest) \\| null>\n\n\n\n\n\n
\n\n[getManifest](#)\n\n\n\n\n\n\n\n() => [QwikManifest](#qwikmanifest) \\| null\n\n\n\n\n\n
\n\n[getOptimizer](#)\n\n\n\n\n\n\n\n() => [Optimizer](#optimizer) \\| null\n\n\n\n\n\n
\n\n[getOptions](#)\n\n\n\n\n\n\n\n() => NormalizedQwikPluginOptions\n\n\n\n\n\n
\n\n[getRootDir](#)\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\n[registerBundleGraphModifier](#)\n\n\n\n\n\n\n\n(modifier: [BundleGraphModifier](#bundlegraphmodifier)) => void\n\n\n\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/optimizer/src/plugins/vite.ts", "mdFile": "qwik.qwikvitepluginapi.md" }, diff --git a/packages/docs/src/routes/api/qwik-optimizer/index.md b/packages/docs/src/routes/api/qwik-optimizer/index.md index d2fe9605443..245b767e7bd 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/index.md +++ b/packages/docs/src/routes/api/qwik-optimizer/index.md @@ -52,6 +52,21 @@ _(Optional)_ string +## BundleGraphModifier + +A function that creates a modified version of the bundle graph. Used to inject routes and their dependencies into the bundle graph. + +```typescript +export type BundleGraphModifier = ( + graph: QwikBundleGraph, + manifest: QwikManifest, +) => QwikBundleGraph; +``` + +**References:** [QwikBundleGraph](#qwikbundlegraph), [QwikManifest](#qwikmanifest) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/optimizer/src/plugins/bundle-graph.ts) + ## ComponentEntryStrategy ```typescript @@ -1395,6 +1410,18 @@ _(Optional)_ [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/optimizer/src/types.ts) +## QwikBundleGraph + +Bundle graph. + +Format: [ 'bundle-a.js', 3, 5 // Depends on 'bundle-b.js' and 'bundle-c.js' 'bundle-b.js', 5, // Depends on 'bundle-c.js' 'bundle-c.js', ] + +```typescript +export type QwikBundleGraph = Array; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/optimizer/src/types.ts) + ## QwikManifest The metadata of the build. One of its uses is storing where QRL symbols are located. @@ -2220,6 +2247,19 @@ Description + + + +[registerBundleGraphModifier](#) + + + + + +(modifier: [BundleGraphModifier](#bundlegraphmodifier)) => void + + + diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index c913d82b698..702641eca91 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -1761,7 +1761,7 @@ ], "kind": "Function", "content": "> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.\n> \n\nLoad the prefetch graph for the container.\n\nEach Qwik container needs to include its own prefetch graph.\n\n\n```typescript\nPrefetchGraph: (opts?: {\n base?: string;\n manifestHash?: string;\n manifestURL?: string;\n nonce?: string;\n}) => JSXOutput\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nopts\n\n\n\n\n{ base?: string; manifestHash?: string; manifestURL?: string; nonce?: string; }\n\n\n\n\n_(Optional)_ Options for the loading prefetch graph.\n\n- `base` - Base of the graph. For a default installation this will default to the q:base value `/build/`. But if more than one MFE is installed on the page, then each MFE needs to have its own base. - `manifestHash` - Hash of the manifest file to load. If not provided the hash will be extracted from the container attribute `q:manifest-hash` and assume the default build file `${base}/q-bundle-graph-${manifestHash}.json`. - `manifestURL` - URL of the manifest file to load if non-standard bundle graph location name.\n\n\n
\n**Returns:**\n\n[JSXOutput](#jsxoutput)", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/components/prefetch.ts", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/buffering/components/prefetch.ts", "mdFile": "qwik.prefetchgraph.md" }, { @@ -1775,7 +1775,7 @@ ], "kind": "Function", "content": "> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.\n> \n\nInstall a service worker which will prefetch the bundles.\n\nThere can only be one service worker per page. Because there can be many separate Qwik Containers on the page each container needs to load its prefetch graph using `PrefetchGraph` component.\n\n\n```typescript\nPrefetchServiceWorker: (opts: {\n base?: string;\n scope?: string;\n path?: string;\n verbose?: boolean;\n fetchBundleGraph?: boolean;\n nonce?: string;\n}) => JSXNode<'script'>\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nopts\n\n\n\n\n{ base?: string; scope?: string; path?: string; verbose?: boolean; fetchBundleGraph?: boolean; nonce?: string; }\n\n\n\n\nOptions for the prefetch service worker.\n\n- `base` - Base URL for the service worker `import.meta.env.BASE_URL` or `/`. Default is `import.meta.env.BASE_URL` - `scope` - Base URL for when the service-worker will activate. Default is `/` - `path` - Path to the service worker. Default is `qwik-prefetch-service-worker.js` unless you pass a path that starts with a `/` then the base is ignored. Default is `qwik-prefetch-service-worker.js` - `verbose` - Verbose logging for the service worker installation. Default is `false` - `nonce` - Optional nonce value for security purposes, defaults to `undefined`.\n\n\n
\n**Returns:**\n\n[JSXNode](#jsxnode)<'script'>", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/components/prefetch.ts", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/buffering/components/prefetch.ts", "mdFile": "qwik.prefetchserviceworker.md" }, { diff --git a/packages/docs/src/routes/api/qwik/index.md b/packages/docs/src/routes/api/qwik/index.md index 88380b14447..ea566c7a151 100644 --- a/packages/docs/src/routes/api/qwik/index.md +++ b/packages/docs/src/routes/api/qwik/index.md @@ -3605,7 +3605,7 @@ _(Optional)_ Options for the loading prefetch graph. [JSXOutput](#jsxoutput) -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/components/prefetch.ts) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/buffering/components/prefetch.ts) ## PrefetchServiceWorker @@ -3659,7 +3659,7 @@ Options for the prefetch service worker. [JSXNode](#jsxnode)<'script'> -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/components/prefetch.ts) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/buffering/components/prefetch.ts) ## ProgressHTMLAttributes diff --git a/packages/qwik-city/src/buildtime/runtime-generation/generate-service-worker.ts b/packages/qwik-city/src/buildtime/runtime-generation/generate-service-worker.ts index 3285d39f27c..ddd63b5d77e 100644 --- a/packages/qwik-city/src/buildtime/runtime-generation/generate-service-worker.ts +++ b/packages/qwik-city/src/buildtime/runtime-generation/generate-service-worker.ts @@ -128,7 +128,7 @@ export function generateLinkBundles( }); } - for (const r of ctx.routes) { + for (const route of ctx.routes) { const linkBundleNames: string[] = []; const addFileBundles = (filePath: string) => { @@ -156,14 +156,14 @@ export function generateLinkBundles( } }; - for (const layout of r.layouts) { + for (const layout of route.layouts) { addFileBundles(layout.filePath); } - addFileBundles(r.filePath); + addFileBundles(route.filePath); if (prefetch) { // process the symbols from insights prefetch - const symbolsForRoute = prefetch.find((p) => p.route === r.routeName); + const symbolsForRoute = prefetch.find((p) => p.route === route.routeName); symbolsForRoute?.symbols?.reverse().forEach((symbol) => { const bundle = symbolToBundle.get(symbol); if (bundle) { @@ -177,11 +177,11 @@ export function generateLinkBundles( } linkBundles.push( - `[${r.pattern.toString()},${JSON.stringify( + `[${route.pattern.toString()},${JSON.stringify( linkBundleNames.map((bundleName) => getAppBundleIndex(appBundles, bundleName)) )}]` ); - routeToBundles[r.routeName] = linkBundleNames; + routeToBundles[route.routeName] = linkBundleNames; } return [`const linkBundles=[${linkBundles.join(',')}];`, routeToBundles] as [ diff --git a/packages/qwik-city/src/buildtime/vite/bundle-graph-modifier.ts b/packages/qwik-city/src/buildtime/vite/bundle-graph-modifier.ts new file mode 100644 index 00000000000..25d62ced719 --- /dev/null +++ b/packages/qwik-city/src/buildtime/vite/bundle-graph-modifier.ts @@ -0,0 +1,43 @@ +import type { QwikBundle, QwikBundleGraph, QwikManifest } from '@builder.io/qwik/optimizer'; +import { removeExtension } from '../../utils/fs'; +import type { BuildRoute } from '../types'; + +export function modifyBundleGraph( + routes: BuildRoute[], + originalGraph: QwikBundleGraph, + manifest: QwikManifest +) { + const ROUTES_SEPARATOR = -2; + + const graph = [...originalGraph, ROUTES_SEPARATOR]; + + routes.forEach((route) => { + const routePath = removeExtension(route.filePath); + const layoutPaths = route.layouts + ? route.layouts.map((layout) => removeExtension(layout.filePath)) + : []; + const routeAndLayoutPaths = [routePath, ...layoutPaths]; + + graph.push(route.routeName); + + for (const [bundleName, bundle] of Object.entries(manifest.bundles)) { + if (isBundlePartOfRoute(bundle, routeAndLayoutPaths)) { + const bundleIndex = originalGraph.indexOf(bundleName); + if (bundleIndex !== -1) { + graph.push(originalGraph.indexOf(bundleName)); + } + } + } + }); + return graph; +} + +function isBundlePartOfRoute(bundle: QwikBundle, routeAndLayoutPaths: string[]) { + if (!bundle.origins) { + return false; + } + for (const bundleOrigin of bundle.origins) { + const originPath = removeExtension(bundleOrigin); + return routeAndLayoutPaths.some((path) => path.endsWith(originPath)); + } +} diff --git a/packages/qwik-city/src/buildtime/vite/bundle-graph-modifier.unit.ts b/packages/qwik-city/src/buildtime/vite/bundle-graph-modifier.unit.ts new file mode 100644 index 00000000000..bb2190ea81e --- /dev/null +++ b/packages/qwik-city/src/buildtime/vite/bundle-graph-modifier.unit.ts @@ -0,0 +1,118 @@ +import { + type QwikBundle, + type QwikBundleGraph, + type QwikManifest, +} from '@builder.io/qwik/optimizer'; +import { describe, expect, test } from 'vitest'; +import type { BuildLayout, BuildRoute } from '../types'; +import { modifyBundleGraph } from './bundle-graph-modifier'; + +describe('modifyBundleGraph', () => { + test(`GIVEN 2 routes, one with a layout + AND a manifest with 3 bundles + THEN the bundle graph should contain a -2 separator + AND the routes and their dependencies`, () => { + const fakeManifest = { + bundles: { + 'fake-bundle1.js': { + size: 0, + imports: ['fake-bundle-static-dep.js'], + origins: ['src/routes/index.tsx'], + }, + 'fake-bundle-static-dep.js': { + size: 0, + dynamicImports: ['fake-bundle-dynamic-dep.js'], + }, + 'fake-bundle-dynamic-dep.js': { + size: 0, + }, + 'fake-bundle-part-of-sub-route.js': { + size: 0, + origins: ['src/routes/subroute/index.tsx', 'src/some/other/component.tsx'], + }, + 'fake-bundle-part-of-layout.js': { + size: 0, + origins: ['src/routes/layout.tsx'], + }, + } as Record, + } as QwikManifest; + + const fakeBundleGraph: QwikBundleGraph = [ + 'fake-bundle1.js', + 2, + 'fake-bundle-static-dep.js', + -1, + 4, + 'fake-bundle-dynamic-dep.js', + 'fake-bundle-part-of-sub-route.js', + 'fake-bundle-part-of-layout.js', + ]; + + const fakeRoutes: BuildRoute[] = [ + { + routeName: '/', + filePath: '/home/qwik-app/src/routes/index.tsx', + }, + { + routeName: '/subroute', + filePath: '/home/qwik-app/src/routes/subroute/index.tsx', + layouts: [ + { + filePath: '/home/qwik-app/src/routes/layout.tsx', + }, + ] as BuildLayout[], + }, + ] as BuildRoute[]; + + const actualResult = modifyBundleGraph(fakeRoutes, fakeBundleGraph, fakeManifest); + + const expectedResult: QwikBundleGraph = [ + ...fakeBundleGraph, + -2, // routes separator + '/', + 0, // fake-bundle1.js + '/subroute', + 6, // fake-bundle-part-of-sub-route.js + 7, // fake-bundle-part-of-layout.js + ]; + + expect(actualResult).toEqual(expectedResult); + }); + + test(`GIVEN a mismatch between the bundle graph and the manifest + THEN the resulted bundle graph routes should not contain -1 (not found) indices `, () => { + const fakeManifest = { + bundles: { + 'fake-bundle1.js': { + size: 0, + origins: ['src/routes/index.tsx'], + }, + // 👇 doesn't exist in the bundle graph for some reason + 'fake-bundle2.js': { + size: 0, + origins: ['src/routes/index.tsx'], + }, + } as Record, + } as QwikManifest; + + const fakeBundleGraph: QwikBundleGraph = ['fake-bundle1.js']; + + const fakeRoutes: BuildRoute[] = [ + { + routeName: '/', + filePath: '/home/qwik-app/src/routes/index.tsx', + }, + ] as BuildRoute[]; + + const actualResult = modifyBundleGraph(fakeRoutes, fakeBundleGraph, fakeManifest); + + const expectedResult: QwikBundleGraph = [ + ...fakeBundleGraph, + -2, // routes separator + '/', + 0, // fake-bundle1.js + ]; + + expect(actualResult).toEqual(expectedResult); + }); +}); diff --git a/packages/qwik-city/src/buildtime/vite/plugin.ts b/packages/qwik-city/src/buildtime/vite/plugin.ts index b7ac8dbb904..34574311a8e 100644 --- a/packages/qwik-city/src/buildtime/vite/plugin.ts +++ b/packages/qwik-city/src/buildtime/vite/plugin.ts @@ -1,33 +1,34 @@ +import type { QwikVitePlugin } from '@builder.io/qwik/optimizer'; import swRegister from '@qwik-city-sw-register-build'; -import { createMdxTransformer, type MdxTransform } from '../markdown/mdx'; -import { basename, join, resolve, extname } from 'node:path'; -import type { Plugin, PluginOption, UserConfig, Rollup } from 'vite'; +import fs from 'node:fs'; +import { basename, extname, join, resolve } from 'node:path'; +import type { Plugin, PluginOption, Rollup, UserConfig } from 'vite'; import { loadEnv } from 'vite'; -import { generateQwikCityPlan } from '../runtime-generation/generate-qwik-city-plan'; -import type { BuildContext } from '../types'; -import { createBuildContext, resetBuildContext } from '../context'; +import { + NOT_FOUND_PATHS_ID, + RESOLVED_NOT_FOUND_PATHS_ID, + RESOLVED_STATIC_PATHS_ID, + STATIC_PATHS_ID, +} from '../../adapters/shared/vite'; +import { postBuild } from '../../adapters/shared/vite/post-build'; +import { patchGlobalThis } from '../../middleware/node/node-fetch'; import { isMenuFileName, normalizePath, removeExtension } from '../../utils/fs'; -import { validatePlugin } from './validate-plugin'; -import type { QwikCityPluginApi, QwikCityVitePluginOptions } from './types'; import { build } from '../build'; -import { ssrDevMiddleware, staticDistMiddleware } from './dev-server'; +import { createBuildContext, resetBuildContext } from '../context'; +import { createMdxTransformer, type MdxTransform } from '../markdown/mdx'; import { transformMenu } from '../markdown/menu'; import { generateQwikCityEntries } from '../runtime-generation/generate-entries'; -import { patchGlobalThis } from '../../middleware/node/node-fetch'; -import type { QwikVitePlugin } from '@builder.io/qwik/optimizer'; -import fs from 'node:fs'; +import { generateQwikCityPlan } from '../runtime-generation/generate-qwik-city-plan'; import { generateServiceWorkerRegister, prependManifestToServiceWorker, } from '../runtime-generation/generate-service-worker'; -import { - NOT_FOUND_PATHS_ID, - RESOLVED_NOT_FOUND_PATHS_ID, - RESOLVED_STATIC_PATHS_ID, - STATIC_PATHS_ID, -} from '../../adapters/shared/vite'; -import { postBuild } from '../../adapters/shared/vite/post-build'; +import type { BuildContext } from '../types'; +import { modifyBundleGraph } from './bundle-graph-modifier'; +import { ssrDevMiddleware, staticDistMiddleware } from './dev-server'; import { imagePlugin } from './image-jsx'; +import type { QwikCityPluginApi, QwikCityVitePluginOptions } from './types'; +import { validatePlugin } from './validate-plugin'; /** @public */ export function qwikCity(userOpts?: QwikCityVitePluginOptions): PluginOption[] { @@ -172,6 +173,7 @@ function qwikCityPlugin(userOpts?: QwikCityVitePluginOptions): any { if (isCityPlan || isSwRegister) { if (!ctx.isDevServer && ctx.isDirty) { await build(ctx); + ctx.isDirty = false; ctx.diagnostics.forEach((d) => { this.warn(d.message); @@ -237,6 +239,10 @@ function qwikCityPlugin(userOpts?: QwikCityVitePluginOptions): any { generateBundle(_, bundles) { // client bundles if (ctx?.target === 'client') { + qwikPlugin!.api.registerBundleGraphModifier((graph, manifest) => { + return modifyBundleGraph(ctx!.routes, graph, manifest); + }); + const entries = [...ctx.entries, ...ctx.serviceWorkers].map((entry) => { return { chunkFileName: entry.chunkFileName, diff --git a/packages/qwik-city/src/runtime/src/api.md b/packages/qwik-city/src/runtime/src/api.md index 53864c93d81..1eca9e385aa 100644 --- a/packages/qwik-city/src/runtime/src/api.md +++ b/packages/qwik-city/src/runtime/src/api.md @@ -450,6 +450,7 @@ export const serverQrl: (qrl: QRL, options?: Server // @public (undocumented) export const ServiceWorkerRegister: (props: { nonce?: string; + verbose?: boolean; }) => JSXNode<"script">; // @public (undocumented) diff --git a/packages/qwik-city/src/runtime/src/client-navigate.ts b/packages/qwik-city/src/runtime/src/client-navigate.ts index 20df78fc502..c5aa930ce36 100644 --- a/packages/qwik-city/src/runtime/src/client-navigate.ts +++ b/packages/qwik-city/src/runtime/src/client-navigate.ts @@ -1,7 +1,13 @@ import { isBrowser } from '@builder.io/qwik'; +import { PREFETCHED_NAVIGATE_PATHS } from './constants'; import type { NavigationType, ScrollState } from './types'; import { isSamePath, toPath } from './utils'; -import { PREFETCHED_NAVIGATE_PATHS } from './constants'; + +declare global { + interface Window { + qwikPrefetchSW?: any[][]; + } +} export const clientNavigate = ( win: Window, @@ -40,12 +46,23 @@ export const newScrollState = (): ScrollState => { }; }; -export const prefetchSymbols = (path: string) => { +export const prefetchSymbols = (path: string, base?: string) => { if (isBrowser) { - path = path.endsWith('/') ? path : path + '/'; - if (!PREFETCHED_NAVIGATE_PATHS.has(path)) { - PREFETCHED_NAVIGATE_PATHS.add(path); - document.dispatchEvent(new CustomEvent('qprefetch', { detail: { links: [path] } })); + // Ensure path has trailing slash for backwards compatibility with qprefetch event + const pathWithSlash = path.endsWith('/') ? path : path + '/'; + if (!PREFETCHED_NAVIGATE_PATHS.has(pathWithSlash)) { + PREFETCHED_NAVIGATE_PATHS.add(pathWithSlash); + + // Get the base from container attributes if not provided + const containerBase = base ?? document.documentElement.getAttribute('q:base') ?? '/'; + (window.qwikPrefetchSW || (window.qwikPrefetchSW = [])).push([ + 'link-prefetch', + containerBase, + pathWithSlash, + ]); + + // Keep the existing event for backwards compatibility + document.dispatchEvent(new CustomEvent('qprefetch', { detail: { links: [pathWithSlash] } })); } } }; diff --git a/packages/qwik-city/src/runtime/src/link-component.tsx b/packages/qwik-city/src/runtime/src/link-component.tsx index 649b5025e58..e96531936de 100644 --- a/packages/qwik-city/src/runtime/src/link-component.tsx +++ b/packages/qwik-city/src/runtime/src/link-component.tsx @@ -1,14 +1,23 @@ -import { component$, Slot, type QwikIntrinsicElements, untrack, $, sync$ } from '@builder.io/qwik'; -import { getClientNavPath, shouldPrefetchData, shouldPrefetchSymbols } from './utils'; +import { + $, + Slot, + component$, + isDev, + sync$, + untrack, + useServerData, + type QwikIntrinsicElements, +} from '@builder.io/qwik'; +import { prefetchSymbols } from './client-navigate'; import { loadClientData } from './use-endpoint'; import { useLocation, useNavigate } from './use-functions'; -import { prefetchSymbols } from './client-navigate'; -import { isDev } from '@builder.io/qwik'; +import { getClientNavPath, shouldPrefetchData, shouldPrefetchSymbols } from './utils'; /** @public */ export const Link = component$((props) => { const nav = useNavigate(); const loc = useLocation(); + const containerAttributes = useServerData>('containerAttributes', {}); const originalHref = props.href; const { onClick$, @@ -44,12 +53,13 @@ export const Link = component$((props) => { if (elm && elm.href) { const url = new URL(elm.href); - prefetchSymbols(url.pathname); + prefetchSymbols(url.pathname, containerAttributes['q:base']); if (elm.hasAttribute('data-prefetch')) { loadClientData(url, elm, { prefetchSymbols: false, isPrefetch: true, + base: containerAttributes['q:base'], }); } } diff --git a/packages/qwik-city/src/runtime/src/qwik-city-component.tsx b/packages/qwik-city/src/runtime/src/qwik-city-component.tsx index 49f801648d8..5965d4bbf5d 100644 --- a/packages/qwik-city/src/runtime/src/qwik-city-component.tsx +++ b/packages/qwik-city/src/runtime/src/qwik-city-component.tsx @@ -1,22 +1,25 @@ import { $, + Slot, + _getContextElement, + _waitUntilRendered, + _weakSerialize, component$, getLocale, + isBrowser, + isDev, + isServer, noSerialize, - Slot, useContextProvider, useServerData, useSignal, useStore, - useTask$, - _getContextElement, - _weakSerialize, useStyles$, - _waitUntilRendered, + useTask$, type QRL, } from '@builder.io/qwik'; -import { isBrowser, isDev, isServer } from '@builder.io/qwik'; import * as qwikCity from '@qwik-city-plan'; +import { clientNavigate } from './client-navigate'; import { CLIENT_DATA_CACHE } from './constants'; import { ContentContext, @@ -31,6 +34,13 @@ import { } from './contexts'; import { createDocumentHead, resolveHead } from './head'; import { loadRoute } from './routing'; +import { + currentScrollState, + getScrollHistory, + restoreScroll, + saveScrollHistory, +} from './scroll-restoration'; +import spaInit from './spa-init'; import type { ClientPageData, ContentModule, @@ -51,14 +61,6 @@ import type { import { loadClientData } from './use-endpoint'; import { useQwikCityEnv } from './use-functions'; import { isSameOrigin, isSamePath, toUrl } from './utils'; -import { clientNavigate } from './client-navigate'; -import { - currentScrollState, - getScrollHistory, - saveScrollHistory, - restoreScroll, -} from './scroll-restoration'; -import spaInit from './spa-init'; /** @public */ export const QWIK_CITY_SCROLLER = '_qCityScroller'; @@ -86,7 +88,7 @@ export interface QwikCityProps { * * @see https://github.com/WICG/view-transitions/blob/main/explainer.md * @see https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API - * @see https://caniuse.com/mdn-api_viewtransition + * @see https://caniuse.com/mdn_api_viewtransition */ viewTransition?: boolean; } @@ -112,6 +114,7 @@ export const QwikCityProvider = component$((props) => { } const urlEnv = useServerData('url'); + const containerAttributes = useServerData>('containerAttributes', {}); if (!urlEnv) { throw new Error(`Missing Qwik URL Env Data`); } @@ -275,7 +278,10 @@ export const QwikCityProvider = component$((props) => { routeInternal.value = { type, dest, forceReload, replaceState, scroll }; if (isBrowser) { - loadClientData(dest, _getContextElement()); + loadClientData(dest, _getContextElement(), { + prefetchSymbols: true, + base: containerAttributes['q:base'], + }); loadRoute(qwikCity.routes, qwikCity.menus, qwikCity.cacheModules, dest.pathname); } diff --git a/packages/qwik-city/src/runtime/src/service-worker/cached-fetch.ts b/packages/qwik-city/src/runtime/src/service-worker/cached-fetch.ts index ea66db546ce..f9c0de0ea08 100644 --- a/packages/qwik-city/src/runtime/src/service-worker/cached-fetch.ts +++ b/packages/qwik-city/src/runtime/src/service-worker/cached-fetch.ts @@ -1,6 +1,10 @@ import type { AwaitingRequests, Fetch } from './types'; import { useCache } from './utils'; +export const logger = { + log: (...args: any[]) => {}, +}; + export const cachedFetch = ( cache: Cache, fetch: Fetch, @@ -55,13 +59,16 @@ export const cachedFetch = ( if (useCache(request, cachedResponse)) { // cached response found and user did not specifically send // a request header to NOT use the cache (wasn't a hard refresh) + logger.log('[ALREADY CACHED]: ', url); resolve(cachedResponse!); } else { // no cached response found or user didn't want to use the cache // do a full network request + logger.log('[NOT YET IN CACHE]: ', url); return fetch(request).then(async (networkResponse) => { if (networkResponse.ok) { // network response was good, let's cache it + logger.log('[STORING IN CACHE]: ', url); await cache.put(url, networkResponse.clone()); } resolve(networkResponse); @@ -73,6 +80,7 @@ export const cachedFetch = ( return cache.match(url).then((cachedResponse) => { if (cachedResponse) { // luckily we have a cached version, let's use it instead of an offline message + logger.log('[PROBABLY OFFLINE, USING CACHED RESPONSE]: ', url); resolve(cachedResponse); } else { // darn, we've got no connectivity and no cached response diff --git a/packages/qwik-city/src/runtime/src/service-worker/prefetch.ts b/packages/qwik-city/src/runtime/src/service-worker/prefetch.ts index d5ff970e5a3..fd5dcac055b 100644 --- a/packages/qwik-city/src/runtime/src/service-worker/prefetch.ts +++ b/packages/qwik-city/src/runtime/src/service-worker/prefetch.ts @@ -1,4 +1,4 @@ -import { cachedFetch } from './cached-fetch'; +import { cachedFetch, logger } from './cached-fetch'; import { awaitingRequests, existingPrefetchUrls, prefetchQueue } from './constants'; import type { AppBundle, Fetch, LinkBundle } from './types'; import { getAppBundleByName, getAppBundlesNamesFromIds } from './utils'; @@ -126,7 +126,7 @@ export const prefetchWaterfall = ( ) => { try { const { baseUrl, requestedBundleName } = splitUrlToBaseAndBundle(requestedBuildUrl); - + logger.log('[PREFETCHING WATERFALL]: ', requestedBundleName); prefetchBundleNames(appBundles, qBuildCache, fetch, baseUrl, [requestedBundleName], true); } catch (e) { console.error(e); diff --git a/packages/qwik-city/src/runtime/src/service-worker/setup.ts b/packages/qwik-city/src/runtime/src/service-worker/setup.ts index 56b86c4c28b..e07eb5fcfa3 100644 --- a/packages/qwik-city/src/runtime/src/service-worker/setup.ts +++ b/packages/qwik-city/src/runtime/src/service-worker/setup.ts @@ -1,9 +1,14 @@ -import { cachedFetch } from './cached-fetch'; +import { cachedFetch, logger } from './cached-fetch'; import { awaitingRequests, qBuildCacheName } from './constants'; import { prefetchBundleNames, prefetchLinkBundles, prefetchWaterfall } from './prefetch'; -import type { AppBundle, LinkBundle, ServiceWorkerMessageEvent } from './types'; +import type { AppBundle, LinkBundle, QPrefetchMessage } from './types'; import { computeAppSymbols, getCacheToDelete, isAppBundleRequest, resolveSymbols } from './utils'; +function log(...args: any[]) { + // eslint-disable-next-line no-console + console.log('Qwik City SW: ', ...args); +} + export const setupServiceWorkerScope = ( swScope: ServiceWorkerGlobalScope, appBundles: AppBundle[], @@ -41,38 +46,47 @@ export const setupServiceWorkerScope = ( })(); }); - swScope.addEventListener('message', async ({ data }: ServiceWorkerMessageEvent) => { - if (data.type === 'qprefetch' && typeof data.base === 'string') { - const qBuildCache = await swScope.caches.open(qBuildCacheName); - const baseUrl = new URL(data.base, swScope.origin); - - if (Array.isArray(data.links)) { - prefetchLinkBundles( - appBundles, - libraryBundleIds, - linkBundles, - qBuildCache, - swFetch, - baseUrl, - data.links - ); + swScope.addEventListener( + 'message', + async ({ data }: { data: QPrefetchMessage | { type: 'verbose' } }) => { + if (data.type === 'verbose') { + logger.log = log; } + if (data.type === 'qprefetch' && data.base && typeof data.base === 'string') { + const qBuildCache = await swScope.caches.open(qBuildCacheName); + const baseUrl = new URL(data.base, swScope.origin); - if (Array.isArray(data.bundles)) { - prefetchBundleNames(appBundles, qBuildCache, swFetch, baseUrl, data.bundles); - } + if (Array.isArray(data.links)) { + logger.log('[PREFETCHING LINKS]: ', data.links); + prefetchLinkBundles( + appBundles, + libraryBundleIds, + linkBundles, + qBuildCache, + swFetch, + baseUrl, + data.links + ); + } - if (Array.isArray(data.symbols)) { - prefetchBundleNames( - appBundles, - qBuildCache, - swFetch, - baseUrl, - resolveSymbols(appSymbols, data.symbols) - ); + if (Array.isArray(data.bundles)) { + logger.log('[PREFETCHING BUNDLES]: ', data.bundles); + prefetchBundleNames(appBundles, qBuildCache, swFetch, baseUrl, data.bundles); + } + + if (Array.isArray(data.symbols)) { + logger.log('[PREFETCHING SYMBOLS]: ', resolveSymbols(appSymbols, data.symbols)); + prefetchBundleNames( + appBundles, + qBuildCache, + swFetch, + baseUrl, + resolveSymbols(appSymbols, data.symbols) + ); + } } } - }); + ); swScope.addEventListener('fetch', (event: FetchEvent) => { const request = event.request; @@ -81,6 +95,7 @@ export const setupServiceWorkerScope = ( const url = new URL(request.url); if (isAppBundleRequest(appBundles, url.pathname)) { + logger.log('[FETCHING APP BUNDLE]: ', url.pathname); event.respondWith( swScope.caches.open(qBuildCacheName).then((qBuildCache) => { prefetchWaterfall(appBundles, qBuildCache, swFetch, url); diff --git a/packages/qwik-city/src/runtime/src/sw-component.tsx b/packages/qwik-city/src/runtime/src/sw-component.tsx index fedb3ffc525..ce2975edc18 100644 --- a/packages/qwik-city/src/runtime/src/sw-component.tsx +++ b/packages/qwik-city/src/runtime/src/sw-component.tsx @@ -2,5 +2,12 @@ import { jsx } from '@builder.io/qwik'; import swRegister from '@qwik-city-sw-register'; /** @public */ -export const ServiceWorkerRegister = (props: { nonce?: string }) => - jsx('script', { dangerouslySetInnerHTML: swRegister, nonce: props.nonce }); +export const ServiceWorkerRegister = (props: { nonce?: string; verbose?: boolean }) => { + const content = props.verbose + ? `globalThis.qwikCitySWVerbose = ${props.verbose}; ${swRegister}` + : swRegister; + return jsx('script', { + dangerouslySetInnerHTML: content, + nonce: props.nonce, + }); +}; diff --git a/packages/qwik-city/src/runtime/src/sw-register.ts b/packages/qwik-city/src/runtime/src/sw-register.ts index e99d6598863..de02a28526a 100644 --- a/packages/qwik-city/src/runtime/src/sw-register.ts +++ b/packages/qwik-city/src/runtime/src/sw-register.ts @@ -1,6 +1,8 @@ /* eslint-disable */ import type { QPrefetchData, QPrefetchMessage } from './service-worker/types'; - +declare global { + var qwikCitySWVerbose: boolean; +} // Source for what becomes innerHTML to the script (( @@ -43,10 +45,20 @@ import type { QPrefetchData, QPrefetchMessage } from './service-worker/types'; if (reg.installing) { reg.installing.addEventListener('statechange', (ev: any) => { if (ev.target.state == 'activated') { + if (globalThis.qwikCitySWVerbose) { + reg.active?.postMessage({ + type: 'verbose', + }); + } initServiceWorker!(); } }); } else if (reg.active) { + if (globalThis.qwikCitySWVerbose) { + reg.active.postMessage({ + type: 'verbose', + }); + } initServiceWorker!(); } }) diff --git a/packages/qwik-city/src/runtime/src/use-endpoint.ts b/packages/qwik-city/src/runtime/src/use-endpoint.ts index eb0e8e31745..6b7bc337b11 100644 --- a/packages/qwik-city/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-city/src/runtime/src/use-endpoint.ts @@ -1,8 +1,8 @@ -import { getClientDataPath } from './utils'; -import { CLIENT_DATA_CACHE } from './constants'; -import type { ClientPageData, RouteActionValue } from './types'; import { _deserializeData } from '@builder.io/qwik'; import { prefetchSymbols } from './client-navigate'; +import { CLIENT_DATA_CACHE } from './constants'; +import type { ClientPageData, RouteActionValue } from './types'; +import { getClientDataPath } from './utils'; export const loadClientData = async ( url: URL, @@ -12,6 +12,7 @@ export const loadClientData = async ( clearCache?: boolean; prefetchSymbols?: boolean; isPrefetch?: boolean; + base?: string; } ) => { const pagePathname = url.pathname; @@ -23,7 +24,7 @@ export const loadClientData = async ( } if (opts?.prefetchSymbols !== false) { - prefetchSymbols(pagePathname); + prefetchSymbols(pagePathname, opts?.base); } let resolveFn: () => void | undefined; diff --git a/packages/qwik/src/core/buffering/buffering.ts b/packages/qwik/src/core/buffering/buffering.ts new file mode 100644 index 00000000000..08f6d7bb171 --- /dev/null +++ b/packages/qwik/src/core/buffering/buffering.ts @@ -0,0 +1 @@ +export function buffering() {} diff --git a/packages/qwik/src/core/components/prefetch.ts b/packages/qwik/src/core/buffering/components/prefetch.ts similarity index 94% rename from packages/qwik/src/core/components/prefetch.ts rename to packages/qwik/src/core/buffering/components/prefetch.ts index aa6f3c0d74f..3b3c1aff9dd 100644 --- a/packages/qwik/src/core/components/prefetch.ts +++ b/packages/qwik/src/core/buffering/components/prefetch.ts @@ -1,9 +1,9 @@ // keep this import from qwik/build so the cjs build works import { isDev } from '@builder.io/qwik/build'; -import { _jsxC } from '../internal'; import type { JSXNode } from '@builder.io/qwik/jsx-runtime'; -import { useServerData } from '../use/use-env-data'; -import type { JSXOutput } from '../render/jsx/types/jsx-node'; +import { _jsxC } from '../../internal'; +import type { JSXOutput } from '../../render/jsx/types/jsx-node'; +import { useServerData } from '../../use/use-env-data'; /** * Install a service worker which will prefetch the bundles. @@ -71,8 +71,8 @@ export const PrefetchServiceWorker = (opts: { [ JSON.stringify(resolvedOpts.base), JSON.stringify(resolvedOpts.manifestHash), - 'navigator.serviceWorker', - 'window.qwikPrefetchSW||(window.qwikPrefetchSW=[])', + 'navigator.serviceWorker', // Service worker container + 'window.qwikPrefetchSW||(window.qwikPrefetchSW=[])', // Queue of messages to send to the service worker. resolvedOpts.verbose, ].join(','), ');', diff --git a/packages/qwik/src/core/components/prefetch.unit.tsx b/packages/qwik/src/core/buffering/components/prefetch.unit.tsx similarity index 97% rename from packages/qwik/src/core/components/prefetch.unit.tsx rename to packages/qwik/src/core/buffering/components/prefetch.unit.tsx index cc33e76b1bb..b7983c75245 100644 --- a/packages/qwik/src/core/components/prefetch.unit.tsx +++ b/packages/qwik/src/core/buffering/components/prefetch.unit.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { PrefetchServiceWorker, PrefetchGraph } from './prefetch'; -import { renderToString } from '../../server/render'; +import { renderToString } from '../../../server/render'; +import { PrefetchGraph, PrefetchServiceWorker } from './prefetch'; const DEBUG = false; function log(...args: any[]) { diff --git a/packages/qwik/src/core/buffering/components/preloader.ts b/packages/qwik/src/core/buffering/components/preloader.ts new file mode 100644 index 00000000000..954ddd236b5 --- /dev/null +++ b/packages/qwik/src/core/buffering/components/preloader.ts @@ -0,0 +1 @@ +// TBD diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index 7fb3c218639..04dcbfa765b 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -1,16 +1,16 @@ ////////////////////////////////////////////////////////////////////////////////////////// // Developer Core API ////////////////////////////////////////////////////////////////////////////////////////// -export { componentQrl, component$ } from './component/component.public'; +export { component$, componentQrl } from './component/component.public'; export type { - PropsOf, - OnRenderFn, Component, - PublicProps, + OnRenderFn, PropFunctionProps, + PropsOf, + PublicProps, _AllowPlainQrl, - _Only$, + _Only$ } from './component/component.public'; export { isBrowser, isDev, isServer } from '@builder.io/qwik/build'; @@ -19,21 +19,20 @@ export { isBrowser, isDev, isServer } from '@builder.io/qwik/build'; // Developer Event API ////////////////////////////////////////////////////////////////////////////////////////// export type { - SnapshotState, - SnapshotResult, + SnapshotListener, SnapshotMeta, SnapshotMetaValue, - SnapshotListener, + SnapshotResult, + SnapshotState } from './container/container'; ////////////////////////////////////////////////////////////////////////////////////////// // Internal Runtime ////////////////////////////////////////////////////////////////////////////////////////// -export { $, sync$, _qrlSync, type SyncQRL } from './qrl/qrl.public'; -export { event$, eventQrl } from './qrl/qrl.public'; +export { $, _qrlSync, event$, eventQrl, sync$, type SyncQRL } from './qrl/qrl.public'; -export { qrl, inlinedQrl, inlinedQrlDEV, qrlDEV } from './qrl/qrl'; -export type { QRL, PropFunction, PropFnInterface } from './qrl/qrl.public'; +export { inlinedQrl, inlinedQrlDEV, qrl, qrlDEV } from './qrl/qrl'; +export type { PropFnInterface, PropFunction, QRL } from './qrl/qrl.public'; export { implicit$FirstArg } from './util/implicit_dollar'; ////////////////////////////////////////////////////////////////////////////////////////// @@ -45,57 +44,71 @@ export type { CorePlatform } from './platform/types'; ////////////////////////////////////////////////////////////////////////////////////////// // JSX Runtime ////////////////////////////////////////////////////////////////////////////////////////// -export { h, h as createElement } from './render/jsx/factory'; -export { - SSRStreamBlock, - SSRRaw, - SSRStream, - SSRComment, - SSRHint, - SkipRender, -} from './render/jsx/utils.public'; -export type { SSRStreamProps, SSRHintProps } from './render/jsx/utils.public'; -export { Slot } from './render/jsx/slot.public'; +export { h as createElement, h } from './render/jsx/factory'; export { Fragment, HTMLFragment, RenderOnce, jsx, jsxDEV, jsxs } from './render/jsx/jsx-runtime'; +export { Slot } from './render/jsx/slot.public'; export type * from './render/jsx/types/jsx-generated'; +export type { DevJSX, FunctionComponent, JSXNode, JSXOutput } from './render/jsx/types/jsx-node'; +export type { QwikJSX as JSX, QwikDOMAttributes, QwikJSX } from './render/jsx/types/jsx-qwik'; export type { - DOMAttributes, - QwikAttributes, - JSXTagName, - JSXChildren, - ComponentBaseProps, ClassList, + ComponentBaseProps, CorrectedToggleEvent, + DOMAttributes, EventHandler, + JSXChildren, + JSXTagName, QRLEventHandlerMulti, + QwikAttributes } from './render/jsx/types/jsx-qwik-attributes'; -export type { JSXOutput, FunctionComponent, JSXNode, DevJSX } from './render/jsx/types/jsx-node'; -export type { QwikDOMAttributes, QwikJSX, QwikJSX as JSX } from './render/jsx/types/jsx-qwik'; +export { + SSRComment, + SSRHint, + SSRRaw, + SSRStream, + SSRStreamBlock, + SkipRender +} from './render/jsx/utils.public'; +export type { SSRHintProps, SSRStreamProps } from './render/jsx/utils.public'; -export type { QwikIntrinsicElements } from './render/jsx/types/jsx-qwik-elements'; -export type { QwikHTMLElements, QwikSVGElements } from './render/jsx/types/jsx-generated'; export { render } from './render/dom/render.public'; -export type { RenderSSROptions, StreamWriter } from './render/ssr/render-ssr'; export type { RenderOptions, RenderResult } from './render/dom/render.public'; +export type { QwikHTMLElements, QwikSVGElements } from './render/jsx/types/jsx-generated'; +export type { QwikIntrinsicElements } from './render/jsx/types/jsx-qwik-elements'; +export type { RenderSSROptions, StreamWriter } from './render/ssr/render-ssr'; ////////////////////////////////////////////////////////////////////////////////////////// // use API ////////////////////////////////////////////////////////////////////////////////////////// -export { useLexicalScope } from './use/use-lexical-scope.public'; -export { useStore } from './use/use-store.public'; +export { createContextId, useContext, useContextProvider } from './use/use-context'; export { untrack } from './use/use-core'; -export { useId } from './use/use-id'; -export { useContext, useContextProvider, createContextId } from './use/use-context'; export { useServerData } from './use/use-env-data'; -export { useStylesQrl, useStyles$, useStylesScopedQrl, useStylesScoped$ } from './use/use-styles'; +export { useId } from './use/use-id'; +export { useLexicalScope } from './use/use-lexical-scope.public'; +export { getLocale, withLocale } from './use/use-locale'; export { useOn, useOnDocument, useOnWindow } from './use/use-on'; -export { useSignal, useConstant, createSignal } from './use/use-signal'; -export { withLocale, getLocale } from './use/use-locale'; +export { createSignal, useConstant, useSignal } from './use/use-signal'; +export { useStore } from './use/use-store.public'; +export { useStyles$, useStylesQrl, useStylesScoped$, useStylesScopedQrl } from './use/use-styles'; -export type { UseStylesScoped } from './use/use-styles'; -export type { UseSignal } from './use/use-signal'; +export type { ErrorBoundaryStore } from './render/error-handling'; export type { ContextId } from './use/use-context'; +export { useErrorBoundary } from './use/use-error-boundary'; +export { Resource, useResource$, useResourceQrl } from './use/use-resource'; +export type { ResourceOptions, ResourceProps } from './use/use-resource'; +export type { UseSignal } from './use/use-signal'; export type { UseStoreOptions } from './use/use-store.public'; +export type { UseStylesScoped } from './use/use-styles'; +export { + createComputed$, + createComputedQrl, + useComputed$, + useComputedQrl, + useTask$, + useTaskQrl, + useVisibleTask$, + useVisibleTaskQrl +} from './use/use-task'; export type { ComputedFn, EagernessOptions, @@ -110,24 +123,17 @@ export type { TaskFn, Tracker, UseTaskOptions, - VisibleTaskStrategy, + VisibleTaskStrategy } from './use/use-task'; -export type { ResourceProps, ResourceOptions } from './use/use-resource'; -export { useResource$, useResourceQrl, Resource } from './use/use-resource'; -export { useTask$, useTaskQrl } from './use/use-task'; -export { useVisibleTask$, useVisibleTaskQrl } from './use/use-task'; -export { useComputed$, useComputedQrl, createComputed$, createComputedQrl } from './use/use-task'; -export { useErrorBoundary } from './use/use-error-boundary'; -export type { ErrorBoundaryStore } from './render/error-handling'; ////////////////////////////////////////////////////////////////////////////////////////// // Developer Low-Level API ////////////////////////////////////////////////////////////////////////////////////////// -export type { ValueOrPromise } from './util/types'; -export type { Signal, ReadonlySignal } from './state/signal'; -export type { NoSerialize } from './state/common'; export { noSerialize, unwrapProxy as unwrapStore } from './state/common'; +export type { NoSerialize } from './state/common'; export { isSignal } from './state/signal'; +export type { ReadonlySignal, Signal } from './state/signal'; +export type { ValueOrPromise } from './util/types'; export { version } from './version'; ////////////////////////////////////////////////////////////////////////////////////////// @@ -135,11 +141,7 @@ export { version } from './version'; ////////////////////////////////////////////////////////////////////////////////////////// export type { KnownEventNames as KnownEventNames, - QwikSymbolEvent, - QwikVisibleEvent, - QwikIdleEvent, - QwikInitEvent, - QwikTransitionEvent, + // old NativeAnimationEvent, NativeClipboardEvent, @@ -154,25 +156,30 @@ export type { NativeUIEvent, NativeWheelEvent, QwikAnimationEvent, + QwikChangeEvent, QwikClipboardEvent, QwikCompositionEvent, QwikDragEvent, - QwikPointerEvent, QwikFocusEvent, - QwikSubmitEvent, + QwikIdleEvent, + QwikInitEvent, QwikInvalidEvent, - QwikChangeEvent, QwikKeyboardEvent, QwikMouseEvent, + QwikPointerEvent, + QwikSubmitEvent, + QwikSymbolEvent, QwikTouchEvent, + QwikTransitionEvent, QwikUIEvent, - QwikWheelEvent, + QwikVisibleEvent, + QwikWheelEvent } from './render/jsx/types/jsx-qwik-events'; ////////////////////////////////////////////////////////////////////////////////////////// // Components ////////////////////////////////////////////////////////////////////////////////////////// -export { PrefetchServiceWorker, PrefetchGraph } from './components/prefetch'; +export { PrefetchGraph, PrefetchServiceWorker } from './buffering/components/prefetch'; ////////////////////////////////////////////////////////////////////////////////////////// // INTERNAL diff --git a/packages/qwik/src/optimizer/src/api.md b/packages/qwik/src/optimizer/src/api.md index 5dc12594d81..e7c0f70cad5 100644 --- a/packages/qwik/src/optimizer/src/api.md +++ b/packages/qwik/src/optimizer/src/api.md @@ -6,6 +6,9 @@ import type { Plugin as Plugin_2 } from 'vite'; +// @public +export type BundleGraphModifier = (graph: QwikBundleGraph, manifest: QwikManifest) => QwikBundleGraph; + // @public (undocumented) export interface ComponentEntryStrategy { // (undocumented) @@ -187,6 +190,9 @@ export interface QwikBundle { symbols?: string[]; } +// @public +export type QwikBundleGraph = Array; + // @public export interface QwikManifest { bundles: { @@ -300,6 +306,8 @@ export interface QwikVitePluginApi { getOptions: () => NormalizedQwikPluginOptions; // (undocumented) getRootDir: () => string | null; + // (undocumented) + registerBundleGraphModifier: (modifier: BundleGraphModifier) => void; } // Warning: (ae-forgotten-export) The symbol "QwikVitePluginCSROptions" needs to be exported by the entry point index.d.ts diff --git a/packages/qwik/src/optimizer/src/index.ts b/packages/qwik/src/optimizer/src/index.ts index e2497947484..278ec62bf81 100644 --- a/packages/qwik/src/optimizer/src/index.ts +++ b/packages/qwik/src/optimizer/src/index.ts @@ -8,9 +8,7 @@ export type { EntryStrategy, GlobalInjections, SegmentAnalysis as HookAnalysis, - SegmentAnalysis, SegmentEntryStrategy as HookEntryStrategy, - SegmentEntryStrategy, InlineEntryStrategy, InsightManifest, MinifyMode, @@ -19,9 +17,12 @@ export type { OptimizerSystem, Path, QwikBundle, + QwikBundleGraph, QwikManifest, QwikSymbol, ResolvedManifest, + SegmentAnalysis, + SegmentEntryStrategy, SingleEntryStrategy, SmartEntryStrategy, SourceLocation, @@ -38,7 +39,7 @@ export type { TranspileOption, } from './types'; -export type { QwikBuildMode, QwikBuildTarget, ExperimentalFeatures } from './plugins/plugin'; +export type { ExperimentalFeatures, QwikBuildMode, QwikBuildTarget } from './plugins/plugin'; export type { QwikRollupPluginOptions } from './plugins/rollup'; export type { QwikViteDevResponse, @@ -47,6 +48,8 @@ export type { QwikVitePluginOptions, } from './plugins/vite'; +export type { BundleGraphModifier } from './plugins/bundle-graph'; + export { qwikRollup } from './plugins/rollup'; export { qwikVite } from './plugins/vite'; export { symbolMapper } from './plugins/vite-dev-server'; diff --git a/packages/qwik/src/optimizer/src/plugins/bundle-graph.ts b/packages/qwik/src/optimizer/src/plugins/bundle-graph.ts new file mode 100644 index 00000000000..b55fcb289a7 --- /dev/null +++ b/packages/qwik/src/optimizer/src/plugins/bundle-graph.ts @@ -0,0 +1,111 @@ +import type { QwikBundleGraph, QwikManifest } from '../types'; + +/** + * A function that creates a modified version of the bundle graph. Used to inject routes and their + * dependencies into the bundle graph. + * + * @public + */ +export type BundleGraphModifier = ( + graph: QwikBundleGraph, + manifest: QwikManifest +) => QwikBundleGraph; + +export function convertManifestToBundleGraph( + manifest: QwikManifest, + bundleGraphModifiers?: Set +): QwikBundleGraph { + let bundleGraph: QwikBundleGraph = []; + const manifestGraph = manifest.bundles; + if (!manifestGraph) { + return []; + } + const names = Object.keys(manifestGraph).sort(); + const map = new Map }>(); + + for (const bundleName of names) { + const bundle = manifestGraph[bundleName]; + const index = bundleGraph.length; + const deps = new Set(bundle.imports); + for (const depName of deps) { + if (!manifestGraph[depName]) { + // external dependency + continue; + } + clearTransitiveDeps(deps, new Set(), depName, manifestGraph); + } + let didAddSeparator = false; + for (const depName of bundle.dynamicImports || []) { + // If we dynamically import a qrl segment that is not a handler, we'll probably need it soon + + if (!manifestGraph[depName]) { + // external dependency + continue; + } + if (!didAddSeparator) { + deps.add(''); + didAddSeparator = true; + } + deps.add(depName); + } + map.set(bundleName, { index, deps }); + bundleGraph.push(bundleName); + while (index + deps.size >= bundleGraph.length) { + bundleGraph.push(null!); + } + } + + // Second pass to to update dependency pointers + for (const bundleName of names) { + const bundle = map.get(bundleName); + if (!bundle) { + console.warn(`Bundle ${bundleName} not found in the bundle graph.`); + continue; + } + // eslint-disable-next-line prefer-const + let { index, deps } = bundle; + index++; + for (const depName of deps) { + if (depName === '') { + bundleGraph[index++] = -1; + continue; + } + const dep = map.get(depName); + if (!dep) { + console.warn(`Dependency ${depName} of ${bundleName} not found in the bundle graph.`); + continue; + } + const depIndex = dep.index; + bundleGraph[index++] = depIndex; + } + } + if (bundleGraphModifiers && bundleGraphModifiers.size > 0) { + for (const modifier of bundleGraphModifiers) { + bundleGraph = modifier(bundleGraph, manifest); + } + } + + return bundleGraph; +} + +function clearTransitiveDeps( + parentDeps: Set, + seen: Set, + bundleName: string, + graph: QwikManifest['bundles'] +) { + const bundle = graph[bundleName]; + if (!bundle) { + // external dependency + return; + } + for (const dep of bundle.imports || []) { + if (parentDeps.has(dep)) { + parentDeps.delete(dep); + } + if (!seen.has(dep)) { + seen.add(dep); + clearTransitiveDeps(parentDeps, seen, dep, graph); + } + } +} diff --git a/packages/qwik/src/optimizer/src/plugins/vite.ts b/packages/qwik/src/optimizer/src/plugins/vite.ts index 158188f6836..829bcd1b7b1 100644 --- a/packages/qwik/src/optimizer/src/plugins/vite.ts +++ b/packages/qwik/src/optimizer/src/plugins/vite.ts @@ -8,11 +8,11 @@ import type { OptimizerOptions, OptimizerSystem, Path, - QwikBundleGraph, QwikManifest, TransformModule, } from '../types'; import { versions } from '../versions'; +import { convertManifestToBundleGraph, type BundleGraphModifier } from './bundle-graph'; import { getImageSizeServer } from './image-size-server'; import { CLIENT_OUT_DIR, @@ -93,6 +93,8 @@ export function qwikVite(qwikViteOpts: QwikVitePluginOptions = {}): any { return null; } + const bundleGraphModifiers = new Set(); + const api: QwikVitePluginApi = { getOptimizer: () => qwikPlugin.getOptimizer(), getOptions: () => qwikPlugin.getOptions(), @@ -102,6 +104,8 @@ export function qwikVite(qwikViteOpts: QwikVitePluginOptions = {}): any { getClientOutDir: () => clientOutDir, getClientPublicOutDir: () => clientPublicOutDir, getAssetsDir: () => viteAssetsDir, + registerBundleGraphModifier: (modifier: BundleGraphModifier) => + bundleGraphModifiers.add(modifier), }; // We provide two plugins to Vite. The first plugin is the main plugin that handles all the @@ -592,6 +596,9 @@ export function qwikVite(qwikViteOpts: QwikVitePluginOptions = {}): any { const assetsDir = qwikPlugin.getOptions().assetsDir || ''; const useAssetsDir = !!assetsDir && assetsDir !== '_astro'; const sys = qwikPlugin.getSys(); + + const bundleGraph = convertManifestToBundleGraph(manifest, bundleGraphModifiers); + this.emitFile({ type: 'asset', fileName: sys.path.join( @@ -599,7 +606,7 @@ export function qwikVite(qwikViteOpts: QwikVitePluginOptions = {}): any { 'build', `q-bundle-graph-${manifest.manifestHash}.json` ), - source: JSON.stringify(convertManifestToBundleGraph(manifest)), + source: JSON.stringify(bundleGraph), }); const fs: typeof import('fs') = await sys.dynamicImport('node:fs'); const workerScriptPath = (await this.resolve('@builder.io/qwik/qwik-prefetch.js'))!.id; @@ -1100,6 +1107,7 @@ export interface QwikVitePluginApi { getClientOutDir: () => string | null; getClientPublicOutDir: () => string | null; getAssetsDir: () => string | undefined; + registerBundleGraphModifier: (modifier: BundleGraphModifier) => void; } /** @@ -1132,87 +1140,3 @@ function absolutePathAwareJoin(path: Path, ...segments: string[]): string { } return path.join(...segments); } - -export function convertManifestToBundleGraph(manifest: QwikManifest): QwikBundleGraph { - const bundleGraph: QwikBundleGraph = []; - const graph = manifest.bundles; - if (!graph) { - return []; - } - const names = Object.keys(graph).sort(); - const map = new Map }>(); - const clearTransitiveDeps = (parentDeps: Set, seen: Set, bundleName: string) => { - const bundle = graph[bundleName]; - if (!bundle) { - // external dependency - return; - } - for (const dep of bundle.imports || []) { - if (parentDeps.has(dep)) { - parentDeps.delete(dep); - } - if (!seen.has(dep)) { - seen.add(dep); - clearTransitiveDeps(parentDeps, seen, dep); - } - } - }; - for (const bundleName of names) { - const bundle = graph[bundleName]; - const index = bundleGraph.length; - const deps = new Set(bundle.imports); - for (const depName of deps) { - if (!graph[depName]) { - // external dependency - continue; - } - clearTransitiveDeps(deps, new Set(), depName); - } - let didAdd = false; - for (const depName of bundle.dynamicImports || []) { - // If we dynamically import a qrl segment that is not a handler, we'll probably need it soon - const dep = graph[depName]; - if (!graph[depName]) { - // external dependency - continue; - } - if (dep.isTask) { - if (!didAdd) { - deps.add(''); - didAdd = true; - } - deps.add(depName); - } - } - map.set(bundleName, { index, deps }); - bundleGraph.push(bundleName); - while (index + deps.size >= bundleGraph.length) { - bundleGraph.push(null!); - } - } - // Second pass to to update dependency pointers - for (const bundleName of names) { - const bundle = map.get(bundleName); - if (!bundle) { - console.warn(`Bundle ${bundleName} not found in the bundle graph.`); - continue; - } - // eslint-disable-next-line prefer-const - let { index, deps } = bundle; - index++; - for (const depName of deps) { - if (depName === '') { - bundleGraph[index++] = -1; - continue; - } - const dep = map.get(depName); - if (!dep) { - console.warn(`Dependency ${depName} of ${bundleName} not found in the bundle graph.`); - continue; - } - const depIndex = dep.index; - bundleGraph[index++] = depIndex; - } - } - return bundleGraph; -} diff --git a/packages/qwik/src/optimizer/src/plugins/vite.unit.ts b/packages/qwik/src/optimizer/src/plugins/vite.unit.ts index f9444683d36..68b52653024 100644 --- a/packages/qwik/src/optimizer/src/plugins/vite.unit.ts +++ b/packages/qwik/src/optimizer/src/plugins/vite.unit.ts @@ -3,12 +3,8 @@ import type { Rollup } from 'vite'; import { assert, expect, suite, test } from 'vitest'; import { normalizePath } from '../../../testing/util'; import type { OptimizerOptions, QwikBundle, QwikManifest } from '../types'; -import { - convertManifestToBundleGraph, - qwikVite, - type QwikVitePlugin, - type QwikVitePluginOptions, -} from './vite'; +import { convertManifestToBundleGraph, type BundleGraphModifier } from './bundle-graph'; +import { qwikVite, type QwikVitePlugin, type QwikVitePluginOptions } from './vite'; const cwd = process.cwd(); @@ -457,11 +453,16 @@ test('command: build, --mode lib with multiple outputs', async () => { }); suite('convertManifestToBundleGraph', () => { - test('empty', () => { - expect(convertManifestToBundleGraph({} as any)).toEqual([]); + test(`GIVEN an empty manifest + THEN should return an empty array`, () => { + const emptyManifest = {} as QwikManifest; + + const actualResult = convertManifestToBundleGraph(emptyManifest); + expect(actualResult).toEqual([]); }); - test('simple file set', () => { + test(`GIVEN a manifest with 2 static bundles and 1 dynamic bundle + THEN should return an efficient array with the correct pointers`, () => { const manifest = { bundles: { 'a.js': { @@ -478,6 +479,44 @@ suite('convertManifestToBundleGraph', () => { }, } as Record, } as QwikManifest; - expect(convertManifestToBundleGraph(manifest)).toEqual(['a.js', 2, 'b.js', 'c.js']); + + const actualResult = convertManifestToBundleGraph(manifest); + + expect(actualResult).toEqual(['a.js', 4, -1, 7, 'b.js', -1, 7, 'c.js']); + }); + + test(`GIVEN a manifest with 2 static bundles and 1 dynamic bundle + AND a modifier that adds routes info to the bundleGraph + THEN the added info should be added to the result`, () => { + const manifest = { + bundles: { + 'a.js': { + size: 0, + imports: ['b.js'], + }, + 'b.js': { + size: 0, + }, + } as Record, + } as QwikManifest; + + const fakeBundleGraphModifier: BundleGraphModifier = (bundleGraph) => { + bundleGraph = [...bundleGraph, -2, '/route', 0]; + return bundleGraph; + }; + + const fakeModifiers = new Set([fakeBundleGraphModifier]); + + const actualResult = convertManifestToBundleGraph(manifest, fakeModifiers); + + expect(actualResult).toEqual([ + 'a.js', + 2, + 'b.js', + -2, + // THIS IS THE ADDED INFO 👇 + '/route', + 0, + ]); }); }); diff --git a/packages/qwik/src/prefetch-service-worker/direct-fetch.ts b/packages/qwik/src/prefetch-service-worker/direct-fetch.ts index 5eafd1ad072..739c0ac4161 100644 --- a/packages/qwik/src/prefetch-service-worker/direct-fetch.ts +++ b/packages/qwik/src/prefetch-service-worker/direct-fetch.ts @@ -117,28 +117,9 @@ export function addDependencies( if (!fetchMap.has(filename)) { fetchMap.set(filename, priority); if (!base.$processed$) { - base.$processed$ = new Map(); - // Process the graph so we don't walk thousands of entries on every lookup. - let current: { $direct$: string[]; $indirect$: string[] }, isDirect; - for (let i = 0; i < base.$graph$.length; i++) { - const item = base.$graph$[i]; - if (typeof item === 'string') { - current = { $direct$: [], $indirect$: [] }; - isDirect = true; - base.$processed$.set(item, current); - } else if (item === -1) { - isDirect = false; - } else { - const depName = base.$graph$[item] as string; - if (isDirect) { - current!.$direct$.push(depName); - } else { - current!.$indirect$.push(depName); - } - } - } + processBundleGraph(base); } - const deps = base.$processed$.get(filename); + const deps = base.$processed$!.get(filename); if (!deps) { return fetchMap; } @@ -148,13 +129,38 @@ export function addDependencies( if (addIndirect) { priority--; for (const dependentFilename of deps.$indirect$) { - // don't add indirect deps of indirect deps - addDependencies(base, fetchMap, dependentFilename, priority, false); + addDependencies(base, fetchMap, dependentFilename, priority); } } } return fetchMap; } + +function processBundleGraph(base: SWStateBase) { + base.$processed$ = new Map(); + // Process the graph so we don't walk thousands of entries on every lookup. + let current: { $direct$: string[]; $indirect$: string[] }, isDirect; + for (let i = 0; i < base.$graph$.length; i++) { + const item = base.$graph$[i]; + if (typeof item === 'string') { + current = { $direct$: [], $indirect$: [] }; + // This tells the next iteration that its item is a direct dependency. (meaning it's before the -1) + isDirect = true; + base.$processed$.set(item, current); + } else if (item === -1) { + // This tells the next iteration that its item is an indirect dependency. (meaning it's AFTER the -1) + isDirect = false; + } else { + const depName = base.$graph$[item] as string; + if (isDirect) { + current!.$direct$.push(depName); + } else { + current!.$indirect$.push(depName); + } + } + } +} + export function parseBaseFilename(url: URL): [string, string] { const pathname = new URL(url).pathname; const slashIndex = pathname.lastIndexOf('/'); diff --git a/packages/qwik/src/prefetch-service-worker/index.unit.tsx b/packages/qwik/src/prefetch-service-worker/index.unit.tsx index 23a42d431bf..c47104bba1b 100644 --- a/packages/qwik/src/prefetch-service-worker/index.unit.tsx +++ b/packages/qwik/src/prefetch-service-worker/index.unit.tsx @@ -1,9 +1,9 @@ +import { describe, expect, it, vi } from 'vitest'; +import { delay } from '../core/util/promises'; +import { addDependencies, directFetch } from './direct-fetch'; +import { processMessage } from './process-message'; import { setupServiceWorker } from './setup'; -import { expect, describe, it, vi } from 'vitest'; import { createState, type SWStateBase, type SWTask } from './state'; -import { processMessage } from './process-message'; -import { addDependencies, directFetch } from './direct-fetch'; -import { delay } from '../core/util/promises'; describe('service-worker', async () => { describe('registration', async () => { @@ -148,6 +148,7 @@ describe('service-worker', async () => { ['b.js', 10], ['c.js', 10], ['e.js', 9], + ['f.js', 8], ['d.js', 9], ]) ); diff --git a/packages/qwik/src/prefetch-service-worker/process-message.ts b/packages/qwik/src/prefetch-service-worker/process-message.ts index 23b530522a7..b70f0385476 100644 --- a/packages/qwik/src/prefetch-service-worker/process-message.ts +++ b/packages/qwik/src/prefetch-service-worker/process-message.ts @@ -53,7 +53,21 @@ export type SWMsgPrefetchAll = [ string, ]; -export type SWMessages = SWMsgBundleGraph | SWMsgBundleGraphUrl | SWMsgPrefetch | SWMsgPrefetchAll; +export type SWMsgLinkPrefetch = [ + /// Message type. + 'link-prefetch', + /// Base URL for the bundles + string, + /// Route path that the link points to + string, +]; + +export type SWMessages = + | SWMsgBundleGraph + | SWMsgBundleGraphUrl + | SWMsgPrefetch + | SWMsgPrefetchAll + | SWMsgLinkPrefetch; export const log = (...args: any[]) => { // eslint-disable-next-line no-console @@ -62,32 +76,34 @@ export const log = (...args: any[]) => { export const processMessage = async (state: SWState, msg: SWMessages) => { const type = msg[0]; - state.$log$('received message:', type, msg[1], msg.slice(2)); + const base = msg[1]; + state.$log$('received message:', type, base, msg.slice(2)); + if (type === 'graph') { - await processBundleGraph(state, msg[1], msg.slice(2), true); + const graph = msg.slice(2); + const doCleanup = true; + await setupBundleGraph(state, base, graph, doCleanup); } else if (type === 'graph-url') { - await processBundleGraphUrl(state, msg[1], msg[2]); + const graphPath = msg[2]; + await handleBundleGraphUrl(state, base, graphPath); } else if (type === 'prefetch') { - await processPrefetch(state, msg[1], msg.slice(2)); + const bundles = msg.slice(2); + await processPrefetch(state, base, bundles); } else if (type === 'prefetch-all') { - await processPrefetchAll(state, msg[1]); + await processPrefetchAll(state, base); + } else if (type === 'link-prefetch') { + const route = msg[2]; + await processLinkPrefetch(state, base, route); } else if (type === 'ping') { - // eslint-disable-next-line no-console log('ping'); } else if (type === 'verbose') { - // eslint-disable-next-line no-console (state.$log$ = log)('mode: verbose'); } else { console.error('UNKNOWN MESSAGE:', msg); } }; -async function processBundleGraph( - swState: SWState, - base: string, - graph: SWGraph, - cleanup: boolean -) { +async function setupBundleGraph(swState: SWState, base: string, graph: SWGraph, cleanup: boolean) { const existingBaseIndex = swState.$bases$.findIndex((b) => b.$path$ === base); if (existingBaseIndex !== -1) { swState.$bases$.splice(existingBaseIndex, 1); @@ -115,14 +131,14 @@ async function processBundleGraph( } } -async function processBundleGraphUrl(swState: SWState, base: string, graphPath: string) { +async function handleBundleGraphUrl(swState: SWState, base: string, graphPath: string) { // Call `processBundleGraph` with an empty graph so that a cache location will be allocated. - await processBundleGraph(swState, base, [], false); + await setupBundleGraph(swState, base, [], false); const response = (await directFetch(swState, new URL(base + graphPath, swState.$url$.origin)))!; if (response && response.status === 200) { const graph = (await response.json()) as SWGraph; graph.push(graphPath); - await processBundleGraph(swState, base, graph, true); + await setupBundleGraph(swState, base, graph, true); } } @@ -151,6 +167,62 @@ function processPrefetchAll(swState: SWState, basePath: string) { } } +function processLinkPrefetch(swState: SWState, basePath: string, routePath: string) { + const base = swState.$bases$.find((base) => basePath === base.$path$); + if (!base) { + console.error( + `Base path not found: ${basePath}, ignoring link prefetch for route: ${routePath}` + ); + return; + } + + // Find bundles for this route from the bundle graph + const ROUTES_SEPARATOR = -2; + const graph = base.$graph$; + const routeSeparatorIndex = graph.indexOf(ROUTES_SEPARATOR); + + if (routeSeparatorIndex === -1) { + console.error(`No routes found in bundle graph for base: ${basePath}`); + return; + } + + // Remove trailing slash for lookup since routes in the bundle graph don't have them + const pathWithoutSlash = routePath.endsWith('/') ? routePath.slice(0, -1) : routePath; + + // Find the route in the graph + const routeIndex = graph.indexOf(pathWithoutSlash, routeSeparatorIndex); + if (routeIndex === -1) { + // Try with trailing slash if not found without it + const withSlash = pathWithoutSlash + '/'; + const altRouteIndex = graph.indexOf(withSlash, routeSeparatorIndex); + if (altRouteIndex === -1) { + console.error(`Route ${routePath} not found in bundle graph`); + return; + } + } + + // Collect all bundle indices after the route until the next route or end + const bundleIndices: number[] = []; + for (let i = routeIndex + 1; i < graph.length; i++) { + const item = graph[i]; + // Stop when we hit the next route (string) or end + if (typeof item === 'string') { + break; + } + if (typeof item === 'number' && item >= 0) { + bundleIndices.push(item); + } + } + + // Convert indices back to bundle names + const routeBundles = bundleIndices.map((index) => graph[index] as string); + + // Use a very low priority for link prefetching since it's the most speculative + const LINK_PREFETCH_PRIORITY = 0; + swState.$log$('link prefetch for route:', routePath, 'bundles:', routeBundles); + enqueueFileAndDependencies(swState, base, routeBundles, LINK_PREFETCH_PRIORITY); +} + export function drainMsgQueue(swState: SWState) { if (!swState.$msgQueuePromise$ && swState.$msgQueue$.length) { const top = swState.$msgQueue$.shift()!; diff --git a/scripts/build.ts b/scripts/build.ts index 11862ab3b34..0f97273f2fe 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -186,6 +186,7 @@ export async function build(config: BuildConfig) { [join(config.srcQwikDir, 'prefetch-service-worker')]: () => submoduleQwikPrefetch(config), [join(config.srcQwikDir, 'server')]: () => submoduleServer(config), [join(config.srcQwikCityDir, 'runtime/src')]: () => buildQwikCity(config), + [join(config.srcQwikCityDir, 'buildtime')]: () => buildQwikCity(config), }); } } catch (e: any) {