From 0e7230fc8c1a61c3de48228a7ca61c59b0b2fdc8 Mon Sep 17 00:00:00 2001 From: cssbruno Date: Thu, 11 Jun 2026 15:52:09 -0300 Subject: [PATCH] fix(dev): include layout component deps in incremental builds --- cmd/gowdk/dev_loop.go | 35 +++++++++-- cmd/gowdk/main_test.go | 84 +++++++++++++++++++++++++++ internal/buildgen/build.go | 10 +++- internal/buildgen/incremental_test.go | 69 ++++++++++++++++++++++ 4 files changed, 191 insertions(+), 7 deletions(-) diff --git a/cmd/gowdk/dev_loop.go b/cmd/gowdk/dev_loop.go index 72504b2..eb51ebd 100644 --- a/cmd/gowdk/dev_loop.go +++ b/cmd/gowdk/dev_loop.go @@ -247,7 +247,7 @@ func newIncrementalDependencyIndex(app gwdkanalysis.Sources) (incrementalDepende index.layoutsBySource[abs] = key } for _, page := range app.Pages { - for key := range pageComponentDependencies(page, componentsByKey) { + for key := range pageComponentDependencies(page, componentsByKey, layoutsByKey) { index.pagesByComponent[key] = append(index.pagesByComponent[key], page.Source) } for key := range pageLayoutDependencies(page, layoutsByKey) { @@ -259,18 +259,41 @@ func newIncrementalDependencyIndex(app gwdkanalysis.Sources) (incrementalDepende return index, true } -func pageComponentDependencies(page gwdkir.Page, components map[string]gwdkir.Component) map[string]bool { +func pageComponentDependencies(page gwdkir.Page, components map[string]gwdkir.Component, layouts map[string]gwdkir.Layout) map[string]bool { seen := map[string]bool{} - refs, err := view.ComponentReferences(page.Blocks.ViewBody) + collectComponentDependenciesFromView(page.Package, page.Uses, page.Blocks.ViewBody, components, seen) + for _, ref := range page.Layouts { + if layout, ok := resolvePageLayoutDependency(page.Package, page.Uses, ref, layouts); ok { + collectLayoutComponentDependencies(layout, layouts, components, map[string]bool{}, seen) + } + } + return seen +} + +func collectComponentDependenciesFromView(ownerPackage string, uses []gwdkir.Use, viewBody string, components map[string]gwdkir.Component, seen map[string]bool) { + refs, err := view.ComponentReferences(viewBody) if err != nil { - return seen + return } for _, ref := range refs { - if component, ok := resolveComponentRef(page.Package, page.Uses, ref, components); ok { + if component, ok := resolveComponentRef(ownerPackage, uses, ref, components); ok { collectComponentDependencies(component, components, seen) } } - return seen +} + +func collectLayoutComponentDependencies(layout gwdkir.Layout, layouts map[string]gwdkir.Layout, components map[string]gwdkir.Component, seenLayouts map[string]bool, seenComponents map[string]bool) { + key := sourceLayoutKey(layout.Package, layout.ID) + if seenLayouts[key] { + return + } + seenLayouts[key] = true + collectComponentDependenciesFromView(layout.Package, layout.Uses, layout.Blocks.ViewBody, components, seenComponents) + for _, ref := range layout.Layouts { + if parent, ok := resolveLayoutDependency(layout.Package, layout.Uses, ref, layouts); ok { + collectLayoutComponentDependencies(parent, layouts, components, seenLayouts, seenComponents) + } + } } func collectComponentDependencies(component gwdkir.Component, components map[string]gwdkir.Component, seen map[string]bool) { diff --git a/cmd/gowdk/main_test.go b/cmd/gowdk/main_test.go index 3c97dc9..b7e0968 100644 --- a/cmd/gowdk/main_test.go +++ b/cmd/gowdk/main_test.go @@ -1920,6 +1920,90 @@ view { } } +func TestBuildIncrementalSPAUsesLayoutComponentDependencies(t *testing.T) { + root := t.TempDir() + page := filepath.Join(root, "home.page.gwdk") + about := filepath.Join(root, "about.page.gwdk") + layout := filepath.Join(root, "root.layout.gwdk") + component := filepath.Join(root, "brand.cmp.gwdk") + outputDir := filepath.Join(root, "dist") + config := writeMinimalCLIConfig(t, root) + writeCLIFile(t, page, `package app + +page home +route "/" +layout root + +view { +
Home
+} +`) + writeCLIFile(t, about, `package app + +page about +route "/about" + +view { +
Stable
+} +`) + writeCLIFile(t, layout, `package app + +view { +
+} +`) + writeCLIFile(t, component, `package app + +component Brand + +view { + Before +} +`) + + args := []string{"--config", config, "--out", outputDir, page, about, layout, component} + if err := build(args); err != nil { + t.Fatal(err) + } + aboutPath := filepath.Join(outputDir, "about", "index.html") + aboutInfo, err := os.Stat(aboutPath) + if err != nil { + t.Fatal(err) + } + + time.Sleep(20 * time.Millisecond) + writeCLIFile(t, component, `package app + +component Brand + +view { + After +} +`) + used, err := buildIncrementalSPA(args, inputChange{Changed: []string{component}}) + if err != nil { + t.Fatal(err) + } + if !used { + t.Fatal("expected incremental spa build to handle layout-only component dependency change") + } + homePayload, err := os.ReadFile(filepath.Join(outputDir, "index.html")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(homePayload), "After") { + t.Fatalf("expected changed layout component output:\n%s", homePayload) + } + afterAboutInfo, err := os.Stat(aboutPath) + if err != nil { + t.Fatal(err) + } + if !afterAboutInfo.ModTime().Equal(aboutInfo.ModTime()) { + t.Fatalf("expected unchanged about output mod time: before=%s after=%s", aboutInfo.ModTime(), afterAboutInfo.ModTime()) + } +} + func TestBuildIncrementalSPAUsesLayoutDependencies(t *testing.T) { root := t.TempDir() page := filepath.Join(root, "home.page.gwdk") diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index 834c63f..511c2af 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -404,6 +404,7 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st components, componentFailures := buildComponents(ir.Components) layouts, layoutFailures := buildLayouts(ir.Layouts) css, cssFailures := planCSS(config, ir, outputDir) + componentAssets, componentAssetFailures := planComponentFileAssets(ir.Assets, outputDir) scopedJS, scopedJSFailures := planScopedJSAssets(ir.Assets, outputDir) baseStylesheets := append([]gowdk.Stylesheet{}, config.Build.Stylesheets...) baseStylesheets = append(baseStylesheets, css.stylesheets...) @@ -412,6 +413,7 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st failures = append(failures, componentFailures...) failures = append(failures, layoutFailures...) failures = append(failures, cssFailures...) + failures = append(failures, componentAssetFailures...) failures = append(failures, scopedJSFailures...) if len(failures) > 0 { return Result{}, reporter.fail("plan", errors.New(strings.Join(failures, "\n"))) @@ -420,7 +422,7 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st if err != nil { return Result{}, reporter.fail("plan", err) } - runtime = append(scopedJS, runtime...) + runtime = append(componentAssets, append(scopedJS, runtime...)...) reporter.info("plan", "artifacts_planned", "incremental artifacts planned", BuildEvent{ Data: map[string]string{ "css": fmt.Sprint(len(css.assets)), @@ -457,6 +459,12 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st } recordWriteStat(&result, wrote) reporter.debug("write", "asset_written", "runtime asset written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) + if artifact.AssetArtifact.Hash == "" { + artifact.AssetArtifact.Hash = contentHash(artifact.contents) + } + if artifact.AssetArtifact.CachePolicy == "" { + artifact.AssetArtifact.CachePolicy = noCacheAssetCachePolicy + } result.AssetArtifacts = append(result.AssetArtifacts, artifact.AssetArtifact) } diff --git a/internal/buildgen/incremental_test.go b/internal/buildgen/incremental_test.go index c542aeb..f28178d 100644 --- a/internal/buildgen/incremental_test.go +++ b/internal/buildgen/incremental_test.go @@ -1,6 +1,7 @@ package buildgen import ( + "encoding/json" "os" "path/filepath" "strings" @@ -10,6 +11,7 @@ import ( "github.com/cssbruno/gowdk" "github.com/cssbruno/gowdk/internal/gwdkanalysis" "github.com/cssbruno/gowdk/internal/gwdkir" + runtimeasset "github.com/cssbruno/gowdk/runtime/asset" ) func TestBuildPreservesUnchangedArtifactModTimes(t *testing.T) { @@ -177,6 +179,73 @@ func TestBuildIncrementalFromIRRendersChangedPageSources(t *testing.T) { } } +func TestBuildIncrementalFromIREmitsComponentFileAssets(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + if err := os.MkdirAll("components", 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(root, "components", "hero.png"), "fake image\n") + outputDir := filepath.Join(root, "dist") + pageSource := "pages/home.page.gwdk" + initial := gwdkanalysis.Sources{ + Pages: []gwdkir.Page{{ + Source: pageSource, + Package: "components", + ID: "home", + Route: "/", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
`, + }, + }}, + Components: []gwdkir.Component{{ + Package: "components", + Source: "components/hero.cmp.gwdk", + Name: "Hero", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
Hero
`, + }, + }}, + } + if _, err := Build(gowdk.Config{}, initial, outputDir); err != nil { + t.Fatal(err) + } + + changed := initial + changed.Components[0].Assets = []string{"./hero.png"} + result, err := BuildIncrementalFromIR(gowdk.Config{}, gwdkanalysis.BuildProgram(gowdk.Config{}, changed), outputDir, []string{pageSource}) + if err != nil { + t.Fatal(err) + } + + logicalPath := "assets/gowdk/components/components/Hero/hero.png" + artifact := assetArtifactByLogicalPath(t, result.AssetArtifacts, logicalPath) + emittedRel := filepath.ToSlash(mustRelativePath(t, outputDir, artifact.Path)) + if emittedRel == logicalPath || !strings.HasPrefix(emittedRel, "assets/gowdk/components/components/Hero/hero.") || !strings.HasSuffix(emittedRel, ".png") { + t.Fatalf("expected content-hashed incremental component asset filename, got %q", emittedRel) + } + if got := readFile(t, artifact.Path); got != "fake image\n" { + t.Fatalf("unexpected emitted asset contents: %q", got) + } + + var assets runtimeasset.Manifest + manifestPayload := readBytes(t, filepath.Join(outputDir, assetManifestFile)) + if err := json.Unmarshal(manifestPayload, &assets); err != nil { + t.Fatal(err) + } + if assets.Resolve(logicalPath) != emittedRel { + t.Fatalf("expected component asset manifest mapping, got %s", manifestPayload) + } + if hash := assets.Hash(logicalPath); !strings.HasPrefix(hash, "sha256:") { + t.Fatalf("expected component asset hash, got %q in %s", hash, manifestPayload) + } + if policy := assets.CachePolicy(logicalPath); policy != immutableAssetCachePolicy { + t.Fatalf("expected immutable component asset cache policy, got %q", policy) + } +} + func TestBuildIncrementalRemovesStaleChangedPageRouteOutput(t *testing.T) { outputDir := t.TempDir() source := filepath.Join(t.TempDir(), "home.page.gwdk")