Skip to content

Conversation

@Markzipan
Copy link
Contributor

Glues FES into build_runner, which is our first step towards hot reload + build_runner.
The high level workflow is:

  1. A persistently running frontend server is initialized once when a build starts.
  2. build_runner requests JS files based on locally modified/generated dart files (as usual). Builders that collect meta-information about DDC modules also - as a side effect - record the main app entrypoint and any locally modified files.
  3. When a JS file is requested, the frontend server receives recompile requests via a proxy queue (to maintain communication order with the frontend server).
  4. The frontend server processes compilation requests and serves compiled JS files back to build_runner (hot-reload ready).

Major changes:

  • Adds a DdcFrontendServerBuilder to our set of DDC builders (enabled via the web-hot-reload config). This builder keeps a PersistentFrontendServer instance alive across rebuilds. Compile/recompile requests are queued via a FrontendServerProxyDriver resource.
  • Uses scratch_space to record both 1) the main app entrypoint and 2) updated local files from the entrypoint_marker builder and the module_builder builder respectively. These are side effects that break certain stateful 'guarantees' of standard build_runner execution. The entrypoint_marker builder runs before any of the downstream DDC builders and finds the web entrypoint, as Frontend Server must receive the same entrypoint on every compilation request.
  • Requires that strongly connected components in both the frontend server and build_runner be disabled.

Test changes:

  • Extends build_test to permit incremental builds. This involves passing the asset graph + asset reader/writer across build results and only performing cleanup operations after a series of rebuilds.
  • build_test doesn't support runs_before and other ordering rules in build.yaml, so the above changes allows a kind of imperative ordering, which is important for testing entrypoint_marker.

Minor changes:

  • Added a flag to disable strongly connected components in build_web_compilers (implemented using raw ddc meta-modules over clean ddc meta-modules + enforcing fine module aggregation).
  • Added disposal logic to scratch_space so that rebuilds only retain modified files.
  • Updated scratch_space package_config.json specs (packageUri and rootUri). The previous values didn't seem to make sense to me, but I'm also not familiar with how that's standardized in scratch_space.
  • Added file and uuid deps to build_modules.
  • Moved around some helper functions.
  • Ported some naming functions from the DDC runtime.

Currently doesn't support live-reloading (functionality appears to have been broken a while ago). This'll be added in an upcoming change and permit webdev-like auto-hot-reload on save (on top of manual).

Enable this by adding the following to a project's build.yaml:

global_options:
  build_web_compilers|sdk_js:
    options:
      web-hot-reload: true
  build_web_compilers|entrypoint:
    options:
      web-hot-reload: true
  build_web_compilers|ddc:
    options:
      web-hot-reload: true
  build_web_compilers|ddc_modules:
    options:
      web-hot-reload: true

@github-actions
Copy link

github-actions bot commented Oct 3, 2025

PR Health

Changelog Entry ✔️
Package Changed Files

Changes to files need to be accounted for in their respective changelogs.

This check can be disabled by tagging the PR with skip-changelog-check.

@davidmorgan
Copy link
Contributor

Thanks!

This will take me a little work to digest :) I guess I can get to it in 1-2 days.

It definitely makes sense to expand support for testing. The details are hard :) ... in particular, I would like to avoid exposing any build_runner internals, including AssetGraph or how it's detecting file changes for follow-on builds.

So I will see if I can come up with a slightly differently suggestion.

Currently testBuilder uses fake build configuration and that's quite awkward; it starts to be weird when you talk about follow-on builds because there is no clear line for what should stay the same in the fake configuration and what's allowed to change.

One possibility would be to go all the way to real builds, I recently added a new type of integration test that I'm pretty happy with

// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file

I'll give it some thought :)

@biggs0125
Copy link
Contributor

biggs0125 commented Oct 3, 2025

Requires that strongly connected components in both the frontend server and build_runner be disabled.

Can you say more about the affect this has on the structure of compilations? Are we doing more work by turning off SCCs? My assumption was that SCCs allowed us to limit the scope of an invalidation.

@Markzipan
Copy link
Contributor Author

Markzipan commented Oct 3, 2025

@davidmorgan Thanks! It's a huge change, so please take your time. I wasn't able to configure it in a prettier way since I'm flying out for two weeks.

I'm highly unopinionated wrt testing, but it was definitely a struggle to get testBuilders to cooperate with rebuilds. Please send any followup recs for better ways to represent this test. The important bits to me are: 1) respecting builder order (as per build.yaml) since that matters now, 2) being able to run rebuilds vs new builds (or determine build boundaries), 3) updating files passed to the builders.

@biggs0125 The SCCs here operate on a the import-graph level. DDC's old module system requires that cyclic dependencies be 'unified' into a single module. Both the Frontend Server and build_runner doing this non-deterministically might cause a <--> b to be merged into a in build_runner and b in the Frontend Server, which gives us "import not found" errors.

With SCCs enabled, a.dart would invoke a single builder on module(a, b). Disabling them, two builders, module(a) and module(b), are invoked. The net effect of this on some apps I've tested hasn't been large, as most modules don't end up in an SCC anyway. The largest ramifications would be a hypothetical gigantic app that wants to bundle smaller JS files. But that would ideally be specified statically, so we'd be able to pass it consistently to both build_runner and the Frontend Server. The particularly annoying bit about SCCs is that both systems maintain their own independent algorithm without any configurability. I alternatively could've 1) poked a hole into the Frontend Server to allow per-compile module-to-lib maps and 2) had build_runner serialize its module-to-lib maps and send that per-compile to the Frontend Server. But I figured that was too much of a mess.

Re: failures. I think some tests are failing due to API changes without pinning new versions? I'll add those (maybe in a separate PR) if the current approach looks sound.

Copy link
Contributor

@davidmorgan davidmorgan left a comment

Choose a reason for hiding this comment

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

This generally looks good, a few comments re: scratch_space.

Re: testing :)

You sort of don't need to pass assetGraph, it's serialized to the readerWriter and the next build will read it if it's there.

But, the next build will discard the asset graph if any builders changed, so you have to pass the exact same builders each time, which means you will get a full build the first time you call it anyway.

You commented with build.yaml ordering is not respected, and that's true. Adding workarounds for that is not too pretty, it's getting very close to a real build but now with quite a lot of configuration that doesn't exactly match a real build.

Would you be up for trying a different way of testing that is a real build?

I added tests recently that make it easy to set up some packages, run build_runner for real and check the output. Since you want persistent processes, watch mode would make sense, so something like:

// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file

There is an example web build using the same test infra here

https://github.com/dart-lang/build/blob/master/build_runner/test/integration_tests/web_compilers_test.dart

The test infra is currently internal to build_runner, mainly build_runner/test/common/build_runner_tester.dart, if it looks useful let's just hack around that for now and I'll work out how best to clean it up, I guess adding to build_test is one option.

@github-actions
Copy link

github-actions bot commented Oct 24, 2025

Package publishing

Package Version Status Publish tag (post-merge)
package:build 4.0.2 already published at pub.dev
package:build_config 1.2.0 already published at pub.dev
package:build_daemon 4.1.0 already published at pub.dev
package:build_modules 5.1.0 ready to publish build_modules-v5.1.0
package:build_runner 2.10.1 already published at pub.dev
package:build_test 3.5.1 already published at pub.dev
package:build_web_compilers 4.4.0 ready to publish build_web_compilers-v4.4.0
package:scratch_space 1.2.0 ready to publish scratch_space-v1.2.0

Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.

@Markzipan
Copy link
Contributor Author

@davidmorgan Wow, the tests in build/build_runner/test/integration_tests are perfect for this. I've replaced the build_web_compilers FES driver test with additional web_compilers_test.dart tests, added a separate resource for FES-specific state, and bumped scratch_space's ver.

Thanks for the review - back from vacation!


/// Bundles information associated with a DDC library.
class LibraryInfo {
final String moduleName;
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the moduleName in the new module system?

/// Logic in this file must be synchronized with their namesakes in DDC at:
/// pkg/dev_compiler/lib/src/compiler/js_names.dart
bool isSdkInternalRuntimeUri(Uri importUri) {
Copy link
Contributor

Choose a reason for hiding this comment

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

OK to make private?

return importUri.isScheme('dart') && importUri.path == '_runtime';
}

String libraryUriToJsIdentifier(Uri importUri) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the JsIdentifier used for? I see it being used later in this change for moduleName but I'm getting confused from there.

if (compilerOutput == null) {
throw Exception('Frontend Server failed to recompile $entrypoint');
}
if (compilerOutput.errorCount != 0 || compilerOutput.errorMessage != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do the reload rejection errors flow here and do these errors get handled somewhere else? We want to make sure the user can recover from them, make some edits and reload again right?

// BSD-style license that can be found in the LICENSE file.

// ignore_for_file: constant_identifier_names

Copy link
Contributor

Choose a reason for hiding this comment

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

At some point we need to unify all of the implementations of this type of library right? In creating this, have you seen any common parts we can factor out and reuse?

}

/// The module name according to ddc for [jsId] which represents the real js
/// module file.
Copy link
Contributor

Choose a reason for hiding this comment

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

I can't quite make sense of what the module name according to DDC means. Is this the path to the Javascript file?

return jsPath.substring(0, jsPath.length - jsModuleExtension.length);
}

String ddcLibraryId(AssetId jsId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

And this is the Dart library import uri? I'm nervous about recreating this from whatever the jsId is. Where does that come from?

@davidmorgan
Copy link
Contributor

It looks like you made some changes in response to my comments but I don't see any responses to the comments so I'm not sure if you're done--please let me know if/when I should take another look. Thanks!

Copy link
Contributor

@davidmorgan davidmorgan left a comment

Choose a reason for hiding this comment

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

Welcome back!

Looking good so far.

There are some CI failures, could you take a look at those please?

);

@override
void reset() {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you don't need this any more?

Copy link
Contributor

Choose a reason for hiding this comment

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

(I think you already removed it, so I guess just close...)

/// Deletes [id] from the [TestReaderWriter] in-memory filesystem.
void delete(AssetId id);

/// Resets state in this [TestReaderWriter] between rebuilds.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you don't need this any more?

});
}, timeout: defaultTimeout);

test('DDC compiled with the Frontend Server', () async {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you please add a TODO for me?

// TODO(davidmorgan): the remaining tests are integration tests of
// the web compilers themselves, support testing like this outside the
// `build_runner` package.

/// defaults to [BuildLog.failurePattern] so that `expect` will stop if the
/// process reports a build failure.
///
/// if [expectFailure] is set, then both [pattern] and [failOn] must be
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmmmm multiple expectations is a bit awkward, I wonder if there's a nicer way.

Would it work to accumulate lines and return them, then the second expectation can be checked on the result?

Looking at how this method is used, I think maybe something like this:

  • most callers are not using the result, so how about the base method is Future<void> expect.
  • a few callers are using the single matching line, so how about Future<String> expectAndGetLine for that
  • and then I think you can do what you want with the full accumulated output until the match, how about Future<List<String>> expectAndGetBlock

?

@davidmorgan
Copy link
Contributor

It looks like you made some changes in response to my comments but I don't see any responses to the comments so I'm not sure if you're done--please let me know if/when I should take another look. Thanks!

Whoops, looks like I had unpublished comments that you addressed by coincidence. Sorry about that! Now published.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants