From 9035bf70a1bd0f495c92ec33484ad3d593c1197b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Mar 2023 16:42:17 -0400 Subject: [PATCH 1/2] queue all updates --- package.json | 3 +- pnpm-lock.yaml | 42 ++-- src/lib/client/adapters/webcontainer/index.js | 219 +++++++++--------- 3 files changed, 138 insertions(+), 126 deletions(-) diff --git a/package.json b/package.json index 470d9fd08..7ca4c9754 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "port-authority": "^2.0.1", "prism-svelte": "^0.5.0", "prismjs": "^1.29.0", - "ws": "^8.12.1" + "ws": "^8.12.1", + "yootils": "^0.3.1" }, "packageManager": "pnpm@7.27.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fadfaaff..b0d32ad12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,11 +44,12 @@ specifiers: typescript: ~4.9.5 vite: ^4.1.4 ws: ^8.12.1 + yootils: ^0.3.1 dependencies: - '@codemirror/autocomplete': 6.4.2 + '@codemirror/autocomplete': 6.4.2_lc2v3dpzp2l5pdzwtgfaudkm3e '@codemirror/commands': 6.2.2 - '@codemirror/lang-css': 6.1.1 + '@codemirror/lang-css': 6.1.1_i3aqn63zftbgivbr4riltn5mqe '@codemirror/lang-html': 6.4.2 '@codemirror/lang-javascript': 6.1.4 '@codemirror/language': 6.6.0 @@ -66,12 +67,13 @@ dependencies: adm-zip: 0.5.10 ansi-to-html: 0.7.2 base64-js: 1.5.1 - codemirror: 6.0.1 + codemirror: 6.0.1_@lezer+common@1.0.2 marked: 4.2.12 port-authority: 2.0.1 prism-svelte: 0.5.0 prismjs: 1.29.0 ws: 8.12.1 + yootils: 0.3.1 devDependencies: '@playwright/test': 1.31.2 @@ -94,8 +96,13 @@ devDependencies: packages: - /@codemirror/autocomplete/6.4.2: + /@codemirror/autocomplete/6.4.2_lc2v3dpzp2l5pdzwtgfaudkm3e: resolution: {integrity: sha512-8WE2xp+D0MpWEv5lZ6zPW1/tf4AGb358T5GWYiKEuCP8MvFfT3tH2mIF9Y2yr2e3KbHuSvsVhosiEyqCpiJhZQ==} + peerDependencies: + '@codemirror/language': ^6.0.0 + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 + '@lezer/common': ^1.0.0 dependencies: '@codemirror/language': 6.6.0 '@codemirror/state': 6.2.0 @@ -112,20 +119,23 @@ packages: '@lezer/common': 1.0.2 dev: false - /@codemirror/lang-css/6.1.1: + /@codemirror/lang-css/6.1.1_i3aqn63zftbgivbr4riltn5mqe: resolution: {integrity: sha512-P6jdNEHyRcqqDgbvHYyC9Wxkek0rnG3a9aVSRi4a7WrjPbQtBTaOmvYpXmm13zZMAatO4Oqpac+0QZs7sy+LnQ==} dependencies: - '@codemirror/autocomplete': 6.4.2 + '@codemirror/autocomplete': 6.4.2_lc2v3dpzp2l5pdzwtgfaudkm3e '@codemirror/language': 6.6.0 '@codemirror/state': 6.2.0 '@lezer/css': 1.1.1 + transitivePeerDependencies: + - '@codemirror/view' + - '@lezer/common' dev: false /@codemirror/lang-html/6.4.2: resolution: {integrity: sha512-bqCBASkteKySwtIbiV/WCtGnn/khLRbbiV5TE+d9S9eQJD7BA4c5dTRm2b3bVmSpilff5EYxvB4PQaZzM/7cNw==} dependencies: - '@codemirror/autocomplete': 6.4.2 - '@codemirror/lang-css': 6.1.1 + '@codemirror/autocomplete': 6.4.2_lc2v3dpzp2l5pdzwtgfaudkm3e + '@codemirror/lang-css': 6.1.1_i3aqn63zftbgivbr4riltn5mqe '@codemirror/lang-javascript': 6.1.4 '@codemirror/language': 6.6.0 '@codemirror/state': 6.2.0 @@ -138,7 +148,7 @@ packages: /@codemirror/lang-javascript/6.1.4: resolution: {integrity: sha512-OxLf7OfOZBTMRMi6BO/F72MNGmgOd9B0vetOLvHsDACFXayBzW8fm8aWnDM0yuy68wTK03MBf4HbjSBNRG5q7A==} dependencies: - '@codemirror/autocomplete': 6.4.2 + '@codemirror/autocomplete': 6.4.2_lc2v3dpzp2l5pdzwtgfaudkm3e '@codemirror/language': 6.6.0 '@codemirror/lint': 6.2.0 '@codemirror/state': 6.2.0 @@ -709,8 +719,8 @@ packages: '@lezer/javascript': ^1.2.0 '@lezer/lr': ^1.0.0 dependencies: - '@codemirror/autocomplete': 6.4.2 - '@codemirror/lang-css': 6.1.1 + '@codemirror/autocomplete': 6.4.2_lc2v3dpzp2l5pdzwtgfaudkm3e + '@codemirror/lang-css': 6.1.1_i3aqn63zftbgivbr4riltn5mqe '@codemirror/lang-html': 6.4.2 '@codemirror/lang-javascript': 6.1.4 '@codemirror/language': 6.6.0 @@ -994,16 +1004,18 @@ packages: engines: {node: '>=10'} dev: true - /codemirror/6.0.1: + /codemirror/6.0.1_@lezer+common@1.0.2: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} dependencies: - '@codemirror/autocomplete': 6.4.2 + '@codemirror/autocomplete': 6.4.2_lc2v3dpzp2l5pdzwtgfaudkm3e '@codemirror/commands': 6.2.2 '@codemirror/language': 6.6.0 '@codemirror/lint': 6.2.0 '@codemirror/search': 6.2.3 '@codemirror/state': 6.2.0 '@codemirror/view': 6.9.2 + transitivePeerDependencies: + - '@lezer/common' dev: false /color-support/1.1.3: @@ -1994,3 +2006,7 @@ packages: /yallist/4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true + + /yootils/0.3.1: + resolution: {integrity: sha512-A7AMeJfGefk317I/3tBoUYRcDcNavKEkpiPN/nQsBz/viI2GvT7BtrqdPD6rGqBFN8Ax7v4obf+Cl32JF9DDVw==} + dev: false diff --git a/src/lib/client/adapters/webcontainer/index.js b/src/lib/client/adapters/webcontainer/index.js index 922643f99..93b13bf23 100644 --- a/src/lib/client/adapters/webcontainer/index.js +++ b/src/lib/client/adapters/webcontainer/index.js @@ -1,6 +1,7 @@ import { WebContainer } from '@webcontainer/api'; import base64 from 'base64-js'; import AnsiToHtml from 'ansi-to-html'; +import * as yootils from 'yootils'; import { escape_html, get_depth } from '../../../utils.js'; import { ready } from '../common/index.js'; @@ -25,12 +26,7 @@ export async function create(base, error, progress, logs) { progress.set({ value: 0, text: 'loading files' }); - /** - * Keeps track of the latest create/reset to ensure things are not processed in parallel. - * (if this turns out to be insufficient, we can use a queue) - * @type {Promise | undefined} - */ - let running; + const q = yootils.queue(1); /** Paths and contents of the currently loaded file stubs */ let current_stubs = stubs_to_map([]); @@ -121,135 +117,134 @@ export async function create(base, error, progress, logs) { } return { - reset: async (stubs) => { - await running; - /** @type {Function} */ - let resolve = () => {}; - running = new Promise((fulfil) => (resolve = fulfil)); - vite_error = false; - - let added_new_file = false; - - const previous_env = /** @type {import('$lib/types').FileStub=} */ ( - current_stubs.get('/.env') - ); - - /** @type {import('$lib/types').Stub[]} */ - const to_write = []; - - for (const stub of stubs) { - if (stub.type === 'file') { - const current = /** @type {import('$lib/types').FileStub} */ ( - current_stubs.get(stub.name) - ); - - if (current?.contents !== stub.contents) { + reset: (stubs) => { + return q.add(async () => { + /** @type {Function} */ + let resolve = () => {}; + vite_error = false; + + const previous_env = /** @type {import('$lib/types').FileStub=} */ ( + current_stubs.get('/.env') + ); + + /** @type {import('$lib/types').Stub[]} */ + const to_write = []; + + for (const stub of stubs) { + if (stub.type === 'file') { + const current = /** @type {import('$lib/types').FileStub} */ ( + current_stubs.get(stub.name) + ); + + if (current?.contents !== stub.contents) { + to_write.push(stub); + } + } else { + // always add directories, otherwise convert_stubs_to_tree will fail to_write.push(stub); } - if (!current) added_new_file = true; - } else { - // always add directories, otherwise convert_stubs_to_tree will fail - to_write.push(stub); + current_stubs.delete(stub.name); } - current_stubs.delete(stub.name); - } - - // Don't delete the node_modules folder when switching from one exercise to another - // where, as this crashes the dev server. - ['/node_modules', '/node_modules/.bin'].forEach((name) => current_stubs.delete(name)); - - const to_delete = Array.from(current_stubs.keys()); - current_stubs = stubs_to_map(stubs); - - // For some reason, server-ready is fired again when the vite dev server is restarted. - // We need to wait for it to finish before we can continue, else we might - // request files from Vite before it's ready, leading to a timeout. - const will_restart = launched && to_write.some(will_restart_vite_dev_server); - const promise = will_restart - ? new Promise((fulfil, reject) => { - const error_unsub = vm.on('error', (error) => { - error_unsub(); - resolve(); - reject(new Error(error.message)); - }); - - const ready_unsub = vm.on('server-ready', (port, base) => { - ready_unsub(); - console.log(`server ready on port ${port} at ${performance.now()}: ${base}`); - resolve(); - fulfil(undefined); - }); - - setTimeout(() => { - resolve(); - reject(new Error('Timed out resetting WebContainer')); - }, 10000); - }) - : Promise.resolve(); - - for (const file of to_delete) { - await vm.fs.rm(file, { force: true, recursive: true }); - } + // Don't delete the node_modules folder when switching from one exercise to another + // where, as this crashes the dev server. + ['/node_modules', '/node_modules/.bin'].forEach((name) => current_stubs.delete(name)); + + const to_delete = Array.from(current_stubs.keys()); + current_stubs = stubs_to_map(stubs); + + // For some reason, server-ready is fired again when the vite dev server is restarted. + // We need to wait for it to finish before we can continue, else we might + // request files from Vite before it's ready, leading to a timeout. + const will_restart = launched && to_write.some(will_restart_vite_dev_server); + const promise = will_restart + ? new Promise((fulfil, reject) => { + const error_unsub = vm.on('error', (error) => { + error_unsub(); + resolve(); + reject(new Error(error.message)); + }); + + const ready_unsub = vm.on('server-ready', (port, base) => { + ready_unsub(); + console.log(`server ready on port ${port} at ${performance.now()}: ${base}`); + resolve(); + fulfil(undefined); + }); + + setTimeout(() => { + resolve(); + reject(new Error('Timed out resetting WebContainer')); + }, 10000); + }) + : Promise.resolve(); + + for (const file of to_delete) { + await vm.fs.rm(file, { force: true, recursive: true }); + } - // Adding a `.env` file does not restart Vite, but environment variables from `.env` - // are not available until Vite is restarted. By creating a dummy `.env` file, it will - // be recognized as changed when the real `.env` file is loaded into the Webcontainer. - // This will invoke a restart of Vite. Hacky but it works. - // TODO: remove when https://github.com/vitejs/vite/issues/12127 is closed - if (!previous_env && current_stubs.has('/.env')) { - await vm.spawn('touch', ['.env']); - } + // Adding a `.env` file does not restart Vite, but environment variables from `.env` + // are not available until Vite is restarted. By creating a dummy `.env` file, it will + // be recognized as changed when the real `.env` file is loaded into the Webcontainer. + // This will invoke a restart of Vite. Hacky but it works. + // TODO: remove when https://github.com/vitejs/vite/issues/12127 is closed + if (!previous_env && current_stubs.has('/.env')) { + await vm.spawn('touch', ['.env']); + } - await vm.mount(convert_stubs_to_tree(to_write)); - await promise; - await new Promise((f) => setTimeout(f, 200)); // wait for chokidar + await vm.mount(convert_stubs_to_tree(to_write)); + await promise; + // await new Promise((f) => setTimeout(f, 200)); // wait for chokidar - resolve(); + resolve(); - // Also trigger a reload of the iframe in case new files were added / old ones deleted, - // because that can result in a broken UI state - const should_reload = !launched || will_restart || vite_error || to_delete.length > 0; - // `|| added_new_file`, but I don't actually think that's necessary? + // Also trigger a reload of the iframe in case new files were added / old ones deleted, + // because that can result in a broken UI state + const should_reload = !launched || will_restart || vite_error || to_delete.length > 0; + // `|| added_new_file`, but I don't actually think that's necessary? - await launch(); + await launch(); - return should_reload; + return should_reload; + }); }, - update: async (file) => { - await running; + update: (file) => { + return q.add(async () => { + /** @type {import('@webcontainer/api').FileSystemTree} */ + const root = {}; - /** @type {import('@webcontainer/api').FileSystemTree} */ - const root = {}; + let tree = root; - let tree = root; + const path = file.name.split('/').slice(1); + const basename = /** @type {string} */ (path.pop()); - const path = file.name.split('/').slice(1); - const basename = /** @type {string} */ (path.pop()); + for (const part of path) { + if (!tree[part]) { + /** @type {import('@webcontainer/api').FileSystemTree} */ + const directory = {}; - for (const part of path) { - if (!tree[part]) { - /** @type {import('@webcontainer/api').FileSystemTree} */ - const directory = {}; + tree[part] = { + directory + }; + } - tree[part] = { - directory - }; + tree = /** @type {import('@webcontainer/api').DirectoryNode} */ (tree[part]).directory; } - tree = /** @type {import('@webcontainer/api').DirectoryNode} */ (tree[part]).directory; - } - - tree[basename] = to_file(file); + tree[basename] = to_file(file); - await vm.mount(root); + await vm.mount(root); - current_stubs.set(file.name, file); + current_stubs.set(file.name, file); - await new Promise((f) => setTimeout(f, 200)); // wait for chokidar + // we need to stagger sequential updates, just enough that the HMR + // wires don't get crossed. 50ms seems to be enough of a delay + // to avoid glitches without noticeably affecting update speed + await new Promise((f) => setTimeout(f, 50)); - return will_restart_vite_dev_server(file); + return will_restart_vite_dev_server(file); + }); } }; } From ce7aef89250d9dcbc2eb2c7fc7676bcfb46deaad Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Mar 2023 17:20:53 -0400 Subject: [PATCH 2/2] remove some unused stuff --- src/lib/client/adapters/webcontainer/index.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/lib/client/adapters/webcontainer/index.js b/src/lib/client/adapters/webcontainer/index.js index 41571bdfa..e2081d186 100644 --- a/src/lib/client/adapters/webcontainer/index.js +++ b/src/lib/client/adapters/webcontainer/index.js @@ -116,9 +116,6 @@ export async function create(base, error, progress, logs) { return { reset: (stubs) => { return q.add(async () => { - /** @type {Function} */ - let resolve = () => {}; - /** @type {import('$lib/types').Stub[]} */ const to_write = []; @@ -154,19 +151,16 @@ export async function create(base, error, progress, logs) { ? new Promise((fulfil, reject) => { const error_unsub = vm.on('error', (error) => { error_unsub(); - resolve(); reject(new Error(error.message)); }); const ready_unsub = vm.on('server-ready', (port, base) => { ready_unsub(); console.log(`server ready on port ${port} at ${performance.now()}: ${base}`); - resolve(); fulfil(undefined); }); setTimeout(() => { - resolve(); reject(new Error('Timed out resetting WebContainer')); }, 10000); }) @@ -180,8 +174,6 @@ export async function create(base, error, progress, logs) { await promise; await new Promise((f) => setTimeout(f, 200)); // wait for chokidar - resolve(); - // Also trigger a reload of the iframe in case new files were added / old ones deleted, // because that can result in a broken UI state const should_reload = !launched || will_restart || to_delete.length > 0;