Skip to content

feat: [#773] add View.Make to render a template to a string - #1561

Open
darakanoit wants to merge 6 commits into
goravel:masterfrom
darakanoit:feat/view-render
Open

darakanoit wants to merge 6 commits into
goravel:masterfrom
darakanoit:feat/view-render

Conversation

@darakanoit

Copy link
Copy Markdown
Contributor

📑 Description

Closes goravel/goravel#773

Implements the first option from the discussion in the issue: rendering lives in the
view module, so a view can be turned into a string outside a request, for example
to warm a page cache from a command or a queued job.

The API follows Laravel's split between the view factory and a view instance.
View::make() in Laravel returns a view object and render() on it produces the
string; here facades.View().Make() returns a Template and Render() produces the
HTML:

html, err := facades.View().Make("pages/home.tmpl", map[string]any{"Title": "Home"}).
	With("Nav", "Menu").
	Render()

html, err := facades.View().First([]string{"custom/admin.tmpl", "admin.tmpl"}, data).Render()
Laravel Goravel
View::make($view, $data) facades.View().Make(view, data...)
View::first($views, $data) facades.View().First(views, data...)
$view->with($key, $value) template.With(key, value)
$view->render() template.Render()
$view->getName() template.Name()
$view->getData() template.Data()

View::exists() and View::share() already had counterparts (Exists, Share).

There are two differences from Laravel. Render() returns (string, error) instead of
throwing. Make and First don't return an error, so invalid data, or a First call
where none of the views exist, is reported when Render() is called. Laravel throws in
first() right away.

Templates are resolved the same way the gin and fiber drivers do it: resources/views
first, then directories from LoadViewsFrom, then filesystems from LoadViewsFromFS,
and the first source that defines a template name wins. First checks candidates with
Exists, like ctx.Response().View().First() does in the drivers. The data can be a
map, a struct or a pointer to a struct. It is copied, so the caller's map is not
modified, and like in Laravel it is merged over the shared data at render time.

The template set is parsed on the first Render() call and kept. Registering a new
view source resets it, so views loaded in a provider's Boot are picked up.

Limitations

Two things differ from ctx.Response().View().Make(), both mentioned in the issue:

  • custom delimiters and template functions are configured per driver
    (http.drivers.<driver>.template), so Render() uses the html/template defaults.
    Moving that config into framework and letting the drivers render through Template,
    the way Laravel's HTTP response renders a view object, can be a follow-up;
  • csrf_token comes from the request session and is not available here.

Verification

Checked in an app built from the skeleton with gin, with framework replaced by this
branch. An artisan command renders welcome.tmpl, a view from LoadViewsFromFS with
shared data and With, and the First, invalid data and missing view cases. Over HTTP,
welcome.tmpl rendered with View().Make(...).Render() is byte for byte identical to
the gin driver's ctx.Response().View().Make(...) output.

Scope

  • contracts/view/view.go: Make and First on View, new Template contract
  • view/template.go: the Template implementation
  • view/render.go: template loading and caching
  • view/view.go: Make, First, and resetting the cached set when a source is registered
  • errors/list.go: ViewInvalidData, ViewNoneExist
  • mocks/view/: regenerated

✅ Checks

  • Added test cases for my code

Rendering now lives in the view module, so a view can be rendered outside a
request, for example to warm a page cache from a command or a queued job.

The API follows Laravel's split between the view factory and a view instance:
View.Make and View.First return a Template, and Template.Render returns the
HTML. Template also has With, Name and Data.

Templates are resolved the same way the gin and fiber drivers do it:
resources/views, then LoadViewsFrom directories, then LoadViewsFromFS
filesystems, first definition wins. The parsed set is cached and reset when a
new view source is registered.
@darakanoit
darakanoit requested a review from a team as a code owner September 19, 2026 11:21
@codecov

codecov Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.04142% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.99%. Comparing base (9385000) to head (13001eb).

Files with missing lines Patch % Lines
view/render.go 94.79% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1561      +/-   ##
==========================================
+ Coverage   72.84%   72.99%   +0.14%     
==========================================
  Files         412      414       +2     
  Lines       26846    27013     +167     
==========================================
+ Hits        19557    19718     +161     
- Misses       7287     7293       +6     
  Partials        2        2              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…ery render

Pointer fields in struct data are dereferenced the way the gin and fiber
drivers do it, so a nil pointer renders as an empty value instead of <nil>.

The compiled template set is read through an atomic pointer, so concurrent
renders no longer queue on a mutex. A parse error is kept with it, like the
drivers keep theirs, instead of re-parsing every view source on each render.
The output buffer is sized from the previous render of the same template,
which avoids growing it and copying it at the end.

Adds BenchmarkTemplateRender.
@darakanoit

darakanoit commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Pushed two more commits:

  • tests for the paths Codecov flagged: a template that fails during execution and a nil struct pointer passed to Make;
  • pointer fields in struct data are now dereferenced the way the gin and fiber drivers do it, so a nil pointer renders as an empty value instead of <nil>. The compiled set is read through an atomic pointer instead of a mutex, a parse error is kept like the drivers keep theirs, and the output buffer is sized from the last render of the same template. On the skeleton's welcome.tmpl this takes a render from 5.2µs and 38 KB to 2.95µs and 20 KB.

One difference from the drivers is left as is: if two files in resources/views define the same template name, the first one wins here, while gin keeps the last one it parses.

@goravel-agent

goravel-agent commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Automated review

This is an AI-generated code review. Please double-check each finding before acting.

Summary

PR #1561 adds View.Make() / View.First() plus a Template render path (view/template.go, view/render.go) with new contracts, errors, mocks and tests, so a view can be rendered to a string outside a request. The implementation is clean, well-tested and faithfully mirrors the gin/fiber drivers, but two correctness gaps in how template names/existence are resolved can produce a failed or silently empty render, alongside some API, performance and polish items.

Verdict

  • Must Fix: 2 · Should Fix: 7 · Nits: 9

Findings

Must Fix

  1. view/view.go:62 First can return a template that cannot render instead of trying the next candidate — First selects with Exists() (file path) while Render() resolves by the name the template was parsed under. For a nested file with no {{ define }} block (e.g. pages/raw.tmpl), Exists("pages/raw.tmpl") is true but parse() registered it as raw.tmpl, so Render() fails with ViewTemplateNotExist and First never falls through to a later candidate like raw.tmpl. Verified against the code. Suggestion: register define-less files under their path relative to the source root (the walked name), or have First confirm the resolved name is renderable before returning.
  2. view/render.go:35 Placeholder template names count as existing views, so missing views render empty with no error — compile() fills sizes from tmpl.Templates(), which includes the empty placeholder created by tmpl.New(stdpath.Base(name)) for a define-only file, plus the unnamed root "". If only layouts/app.tmpl (defining "layouts/app.tmpl") exists, Make("app.tmpl").Render() passes the existence check and returns "" with nil error, and Make("").Render() returns html/template's "incomplete template" error instead of ViewTemplateNotExist. Reproduced in a scratch program. Suggestion: record the actual view names (define names, or the file path for define-less files) in compiled and use that set for the existence check rather than Templates().

Should Fix

  1. view/render.go:117 Only the first {{ define }} in a file participates in precedence dedup — FindStringSubmatch returns one name, so an app file defining several templates marks only the first as claimed and a later package file silently overrides the rest, contradicting the documented "already defined … is skipped" rule. Verified: app primary.tmpl+shared.tmpl yields PKG-SHARED. Suggestion: collect all define names (FindAllStringSubmatch, or derive from the parsed template's Templates()) and skip a file when any of its names is claimed.
  2. view/template.go:29 The template data map is exposed and mutated without synchronization — Data() returns the live internal map and With() writes to it unlocked, while sibling accessors return defensive copies; a stored Template rendered from several goroutines can hit Go's fatal "concurrent map read and map write". Suggestion: return a copy from Data() and guard data with a mutex, or document that a Template must not be shared across goroutines.
  3. contracts/view/view.go:10 Extending contracts/view.View with Make/First is a breaking change and duplicates contracts/http.ResponseView — contracts/http/response.go:90 already declares Make/First, so the framework publishes two competing render APIs with different return types, and any external implementation or hand-written mock of contracts/view.View stops compiling. Suggestion: expose these on a separate contract that view.View also satisfies, or confirm and document the break.
  4. view/template.go:79 toMap reflect-copies the data on every render, boxing each value — Make(...).Render() uses the reflective MapRange+Interface() path for map[string]any (the common case) with no capacity hint; measured ~400× the allocations of a typed copy for a 1000-entry map. Suggestion: add a map[string]any/*map[string]any fast path with make(map[string]any, len(m)), and pre-size reflective branches with value.Len()/value.NumField().
  5. view/render.go:31 A single malformed view file disables every template — render returns the cached c.err for all names, so one broken file (or any stray non-template file, since all extensions are parsed) makes Make("good.tmpl").Render() fail. Suggestion: parse sources independently so unrelated views keep rendering, or document and test the blast radius.
  6. view/render.go:87 The template compiler duplicates the drivers and hardcodes delimiters/FuncMap — parse()+defineRe reimplement driver resolution but ignore http.drivers.<driver>.template (Delims, FuncMap), so a view with custom delimiters renders over HTTP yet emits raw text through Make().Render() with no error. Suggestion: extract one shared resolver/engine used by core and drivers, or delegate to the configured driver.
  7. view/template_test.go:51 The failure modes above are untested — no test covers a nested define-less view through First, an empty candidate list, invalid data for a non-existent view, With on a failed First, or LoadViewsFrom/resetCompiled racing renders. Suggestion: add table cases and a -race subtest registering a source while other goroutines render.

Nits

  1. view/view.go:68 First masks the invalid-data error — it unconditionally replaces NewTemplate's ViewInvalidData with ViewNoneExist. Suggestion: only assign when template.err == nil, or wrap both.
  2. contracts/view/view.go:48 Missing doc comments on new exported symbols — the Template interface, view.Template and NewTemplate are undocumented though the repo requires it; Make is also placed after the Registered* methods. Suggestion: add the comments and move Make up.
  3. view/template.go:62 Variadic data silently uses only data[0] — Make("v", d1, d2) drops d2 with no error, which the signature invites. Suggestion: document the single-data semantic or reject extras.
  4. view/render.go:41 Unbounded allocation hints — the last output size permanently pre-allocates every later render, and make(map[string]any, len(r.data)+8) ignores shared-key count. Suggestion: clamp the Grow hint and size the merge from the actual shared count.
  5. view/render.go:17 Brittle template scanning — defineRe misses {{- define "x" }} and backtick-quoted names (dedup bypassed), and all file extensions are parsed, so .DS_Store/backups can break compilation. Suggestion: tolerate trim markers/backticks and filter to .tmpl as the fiber driver does.
  6. errors/list.go:378 ViewInvalidData wording and error naming — the message says "map or struct" though a non-string-keyed map is a map; ViewNoneExist breaks ViewTemplateNotExist naming and both entries are appended out of alphabetical order. Suggestion: reword to "a map with string keys or a struct", consider ViewNotFound, and keep the block sorted.
  7. view/view.go:27 No compile-time interface assertions — the provider binds *View as any, so a contract change fails only at runtime. Suggestion: add var _ contractsview.View = (*View)(nil) and var _ contractsview.Template = (*Template)(nil).
  8. view/template_test.go:121 Weak assertion / shared globals — TestTemplateRender_ParseError accepts any error (passes even if the file silently failed to load), and setupAppViews mutates support.Config.Paths globals, forcing the package serial. Suggestion: pin the error (assert.ErrorContains(t, err, "broken.tmpl")) and isolate or document the global mutation.
  9. view/render.go:89 App views path ignores the configured Paths.Views — parse()/Exists resolve Resources+views while make:view writes to support.Config.Paths.Views, so a customized views path is invisible (the drivers share this, using path.Resource("views")). Suggestion: derive the app views directory from support.Config.Paths.Views via one shared helper.

Automated Checks

  • gofmt -l view contracts/view errors — clean
  • go vet ./view/... ./contracts/view/... — clean
  • go test ./view/... -race -run 'TestTemplate|TestFirst|TestLoadViews|TestView' -count=1 — pass

Rendering looked views up in the template set html/template happens to end
up with, which holds more than the views themselves. A file that only holds
define blocks leaves an empty template named after it, and every set carries
an unnamed root, so Make("app.tmpl") on a resources/views that only has
layouts/app.tmpl returned an empty string and no error at all.

Names are now collected while the sources are parsed, and only those can be
rendered. A file contributes each of its define blocks and, when it has text
of its own around them, its path within the source as well as its base name,
so a nested file without a define block is addressable the way Exists and the
route drivers address it. First asks whether a candidate can be rendered
rather than whether the file exists, so it no longer settles on one it cannot
render, and it keeps the error from invalid data instead of replacing it.

Files are parsed one at a time and their templates moved into the set
individually, so a package that takes a name it does not own loses that name
and not the rest of the file. This drops the regular expression the sources
were scanned with, which missed {{- define }} and let a package override
every define in a file but the first.

Copying the data of a map[string]any no longer goes through reflect, which
boxed every value: 12 allocations become 2 for a small map. Data returns a
copy, like the accessors next to it.
@darakanoit

darakanoit commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Pushed a commit for the automated review above. Both Must Fix findings reproduce, and so does most of the rest.

Rendering resolved names against the template set html/template ends up with, which holds more than the views. A file of nothing but define blocks leaves an empty template named after it, and every set carries an unnamed root, so Make("app.tmpl") with only layouts/app.tmpl in resources/views returned an empty string and no error. Names are now collected while the sources are parsed and only those render. A file contributes each of its define blocks and, when it has text of its own around them, its path within the source plus its base name, so a nested file without a define block renders under the name Exists answers for.

First now asks whether a candidate can be rendered instead of whether the file exists, so it no longer stops on one it cannot render, and it keeps a data error instead of replacing it with ViewNoneExist.

Files are parsed one at a time and their templates are moved into the set individually. I did not take the suggested fix for the define precedence finding: skipping a package file whole because one of its names is taken costs the package every other template in that file, where installing name by name costs it just that name. This also drops the regular expression the sources were scanned with, which is what missed {{- define }}.

toMap copies a map[string]any directly instead of going through reflect, which boxed every value: 12 allocations become 2 for a small map, 2022 become 6 for a thousand entries. Data() returns a copy like the accessors next to it. The rest is doc comments, error ordering, the interface assertions, and a parse-error assertion that no longer passes when the file is simply missing.

Two behaviour notes for review. Data() is a copy now, so mutating it no longer changes the template. And parse and execution errors name the file path with a line instead of the base name:

template: pages/body.tmpl:2:3: executing "body.tmpl" at <len .N>: error calling len: len of type int

Three findings I did not act on. contracts/http.ResponseView is not a competing API: it returns a Response where this returns a Template, the same split Laravel has between the response and the view object. Custom delimiters and FuncMap are already in the Limitations section above. The variadic data matches the signature used across the framework.

@darakanoit

Copy link
Copy Markdown
Contributor Author

Some numbers behind the Limitations section, because the risk in it is easy to read past.

The limitation. Delimiters and template functions are configured per driver, through http.drivers.<driver>.template. Render() does not see that configuration and uses the html/template defaults.

The risks, worst first.

  1. Wrong output, no error. With custom delimiters a view parses as literal text, so Render() answers 200 and returns the template source instead of the page. Nothing reports a problem. This is the one I would worry about: the feature exists to produce pages that get cached, and the caller has no signal that what it cached is the source of the template.

  2. One view takes down all of them. A view calling a function from the driver's FuncMap fails to parse, and since every view is parsed into one set, Render() returns that parse error for every view in the application:

config: FuncMap{"upper": strings.ToUpper}

func.tmpl, uses {{ upper .Name }}   driver 200  hello GORAVEL
                                    Render() 500  template: func.tmpl:1: function "upper" not defined

welcome.tmpl, does not use upper    driver 200  <!DOCTYPE html>...
                                    Render() 500  template: func.tmpl:1: function "upper" not defined

The error also names a file the caller never asked for, which makes it hard to act on.

  1. Risk 1 turns into risk 2 as soon as the markup keeps {{ }} for a JavaScript framework, which is the usual reason to change delimiters at all:
config: Delims{Left: "<%", Right: "%>"}

vue.tmpl, <% define "vue.tmpl" %><div id="app">{{ user.name }}</div><% end %>
  driver 200  <div id="app">{{ user.name }}</div>
  Render() 500  template: vue.tmpl:1: function "user" not defined

What resolving this requires. The configuration cannot simply be read from where it is. http.drivers.<driver>.template does not hold settings, it holds a built driver renderer, render.HTMLRender for gin and fiber.Views for fiber. There is nothing in it for framework to consume, so this needs two decisions rather than an implementation:

  1. Whether we go to a single renderer, with the drivers rendering through Template and dropping their own template.go. That removes all three risks by deletion. Laravel works this way: view() returns one object, the HTTP response asks it for its string, and Blade::directive() is registered in a service provider against the one compiler, so no configuration exists that the HTTP layer knows about and the renderer does not.
  2. What happens to the delimiter setting specifically. Laravel has no such setting; @{{ name }} and @verbatim escape the places that need it instead. So it either moves into framework or is replaced by escaping, and either way http.drivers.<driver>.template changes shape for applications that use it and needs a deprecation path.

What can be done without deciding either. Parsing sources independently, so one failing view does not take the rest with it, would contain risks 2 and 3, and would also stop a stray non-template file in resources/views from doing the same. It does nothing for risk 1. I can add it here or send it separately, whichever you prefer.

None of this affects the API this PR adds. Make, First and Template on contracts/view stay the same under either answer.

@hwbrzzl

@goravel-agent

goravel-agent commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Automated review

This is an AI-generated code review. Please double-check each finding before acting. You don't need to address every issue if they are inaccurate, but please point them out if any exist.

Summary

PR #1561 adds View.Make() / View.First() and a Template render path (view/template.go, view/render.go) with new contracts, errors, mocks and tests, so a view can be rendered to a string outside a request. The earlier round's two Must Fix items are verified fixed and the code is otherwise clean, but a few correctness gaps remain around template-name resolution and some docs/tests lag behind the latest behaviour.

Verdict

  • [NEW] Must Fix: 0 · Should Fix: 4 · Nits: 8
  • ROUND 1 Must Fix: 0 · Should Fix: 2 · Nits: 2 · Resolved: 14

Findings

Should Fix

  1. view/render.go:157 Empty define and block names render as empty views
    Problem: A file that declares an empty {{ define "empty.name" }}{{ end }} or a bare {{ block "content" . }}{{ end }} contributes that name to the renderable set, so Make("empty.name").Render() returns "" with a nil error.
    Impact: First can silently select such a name and return an empty page instead of falling through to a real candidate, which is the class of bug the previous round fixed for placeholder names.
    Fix: Keep empty trees in the set so {{ template }} references still resolve, but only add non-empty trees to names.

    // before
    if !contributed[name] {
        contributed[name] = true
        names = append(names, name)
    }
    // after
    if !contributed[name] {
        contributed[name] = true
        if !parse.IsEmptyTree(tree.Root) {
            names = append(names, name)
        }
    }
  2. view/render.go:65 First reports missing views when a base-name candidate exists but parsing failed
    Problem: When the sources fail to parse, renderable falls back to Exists, which only matches the file path, so a candidate given by base name (a supported way to address a nested view) is treated as missing.
    Impact: First([]string{"raw.tmpl"}) returns ViewNoneExist instead of the parse error the fallback exists to surface.
    Fix: When compilation failed, treat the candidate as renderable so Render reports the parse error (the existing fallback test still passes).

    // before
    if c.err != nil {
        return r.Exists(view)
    }
    // after
    if c.err != nil {
        // Cannot tell which names resolve; let Render report the parse error.
        return true
    }
  3. view/template.go:22 Copy promise is only shallow
    Problem: The doc says the data "is copied, so later changes to the caller's value are not picked up", but only the top-level map is copied; nested maps and slices stay shared with the caller.
    Impact: Mutating a nested value after Make changes what Render outputs, and doing it from another goroutine while rendering is a data race.
    Fix: Copy nested values too, or say the copy is shallow.

    // before
    // and is copied, so later changes to the caller's value are not picked up.
    // after
    // and is copied at the top level only: nested maps and slices are still
    // shared with the caller and must not be mutated after this call.
  4. view/template_test.go:122 Parse-error test also passes when the view is missing
    Problem: The assertion only checks the error text contains the file name, and ViewTemplateNotExist ("view template broken.tmpl does not exist") contains it too.
    Impact: A regression that reports a missing view instead of a parse error would still pass this test.
    Fix: Also assert the error is not the not-exist sentinel.

    // before
    assert.ErrorContains(t, err, "broken.tmpl")
    // after
    assert.ErrorContains(t, err, "broken.tmpl")
    assert.NotErrorIs(t, err, errors.ViewTemplateNotExist)

Nits

  1. contracts/view/view.go:8 First doc says "exists" but selection is by renderability
    Problem: The comment promises the first view in the list that exists, while First skips a file that exists but only holds define blocks.
    Impact: Callers get ViewNoneExist for a candidate that Exists reports as true.
    Fix: Reword to "the first view in the list that can be rendered".

  2. view/view.go:72 Failed First names an empty view in the error
    Problem: The no-match branch builds NewTemplate(r, "", ...), so an invalid-data error formats the view name as "".
    Impact: The message reads view data must be a map..., hiding which call failed.
    Fix: Pass the candidate list instead of "".

  3. view/view.go:62 New exported First and Make have no doc comments
    Problem: The two main entry points of the feature carry no doc comment, though the contract methods and Template do.
    Impact: go doc shows bare signatures for the new feature.
    Fix: Add a one-line comment to First and to Make.

  4. view/render.go:7 path import aliased differently than in view.go
    Problem: render.go aliases stdlib path as stdpath although no path identifier collides, while view.go imports it unaliased.
    Impact: Two files in the same package refer to the same package under different names.
    Fix: Drop the alias, or use it consistently.

  5. view/template_test.go:460 Pointer-map copy guarantee is not actually tested
    Problem: The comment says a later change to the caller's map is not picked up, but the value is changed before Make and then overridden with .With, so the test passes either way.
    Impact: The *map[string]any copy path can regress unnoticed.
    Fix: Build the template, mutate the caller's map, then render without With.

    // before
    data["Name"] = "changed"
    html, err = view.Make("greet.tmpl", &data).With("Name", "With").Render()
    // after
    tpl := view.Make("greet.tmpl", &data)
    data["Name"] = "changed"
    html, err = tpl.Render()
  6. view/template_test.go:489 Benchmark measures construction as well as rendering
    Problem: Each loop iteration calls Make, so the result is dominated by map construction rather than the render path the PR optimizes.
    Impact: The benchmark cannot show whether the buffer/atomic changes help.
    Fix: Build the template once and benchmark Render alone.

  7. view/view_test.go:278 Test helper mutates process-wide globals
    Problem: setupAppViews writes support.RelativePath and support.Config.Paths.Resources, which the whole package shares.
    Impact: Any test adding t.Parallel() will race on these globals.
    Fix: Document that the helper is serial-only, or give each View its own resource root.

  8. contracts/view/view.go:10 New interface methods break external implementers
    Problem: Adding First and Make to the published View interface stops any external implementation or hand-written mock from compiling.
    Impact: A framework update silently breaks custom bindings of contractsbinding.View.
    Fix: Confirm the break is allowed for this release, or put the new methods on a separate opt-in contract.

The two still-open [ ] items from Round 1 remain reported there: view/render.go A single malformed view file disables every template (Should Fix, verified: a broken file still fails Make("good.tmpl").Render()) and view/render.go The template compiler duplicates the drivers and hardcodes delimiters/FuncMap (Should Fix), plus the view/render.go Unbounded allocation hints and App views path ignores the configured Paths.Views nits.

An empty define or a bare {{ block }} placeholder no longer counts as a
view, so Make reports it as missing and First falls through to the next
candidate. The name becomes renderable once a body for it is parsed,
whichever of the two is walked first.

When the sources fail to parse, First picks the first candidate so Render
reports the parse error, including for a candidate given by its base name.
A First that matched nothing names its candidates in a data error.
@darakanoit

Copy link
Copy Markdown
Contributor Author

Pushed a commit for round 2. All four Should Fix findings reproduced.

An empty {{ define }} or a bare {{ block }} placeholder no longer counts as a view: Make reports it as ViewTemplateNotExist and First moves on to the next candidate. The empty tree still goes into the set, so layouts resolve their placeholders. I did not use the suggested snippet as written. contributed is set on the first claim, so when a layout's empty {{ block "content" }} is walked before the page that fills it, the page's body never makes content renderable. Whether a name is renderable is now tracked on its own, and a test walks the layout first.

When the sources fail to parse, First now treats every candidate as renderable, so a candidate given by its base name gets the parse error instead of ViewNoneExist. The NewTemplate doc now says the copy is shallow, and the parse-error test also asserts the error is not ViewTemplateNotExist.

From the nits: the First doc says "can be rendered" instead of "exists", a First that matched nothing names its candidates in a data error (view [missing.tmpl] data must be ...), First and Make have doc comments, the stdpath alias is gone, the pointer-map test mutates the map after Make and renders without With, and setupAppViews says it must not run in parallel. That helper and its globals predate this PR.

Not changed:

  • Benchmark measures construction. Make is 123 ns out of 50 µs, about 0.25% (split locally: make+render 50.7 µs, render 50.2 µs, make 124 ns / 3 allocs). Make(...).Render() is also how the API is meant to be called, so I kept it end to end.
  • New interface methods break external implementers. Adding methods to contracts is how the framework has grown them so far, and the mocks are regenerated. @hwbrzzl, your call if this should wait for a major version or go on a separate contract.
  • Unbounded allocation hints (round 1). The hint is the last output size of the same template, not a running maximum, so it follows the template back down. sync.Map has no length, so the shared count would cost an extra Range on every render to replace a +8.
  • App views path ignores Paths.Views (round 1). Exists on master and both drivers resolve Resources + views. Switching only Render would make it disagree with Exists and with the HTTP response for the same view, so this should be one change across the view package and the drivers. It can go in a follow-up.
  • One malformed file disables every template and custom delimiters / FuncMap (round 1) are still waiting on the decision in my comment above.

@darakanoit

Copy link
Copy Markdown
Contributor Author

Merged master into this branch.

The integration job still fails in TestValidationTestSuite. That test fails on master as well, since #1548, so it is not caused by this PR. The fix is in goravel/example#155.

@goravel-agent

Copy link
Copy Markdown
Contributor

🤖 Automated review

This is an AI-generated code review. Please double-check each finding before acting. You don't need to address every issue if they are inaccurate, but please point them out if any exist.

Summary

PR #1561 adds View.Make() / View.First() plus a Template render path (view/template.go, view/render.go) with new contracts, errors, mocks and tests, so a view can be rendered to a string outside a request. The previous rounds' fixes hold and most earlier findings are resolved, but a re-verified name-resolution bug still lets a later empty {{ define }} silently blank a view that already has a body, and a few ownership, name-normalization and polish items remain.

Verdict

  • [NEW] Must Fix: 1 · Should Fix: 3 · Nits: 5
  • ROUND 2 Must Fix: 0 · Should Fix: 0 · Nits: 2 · Resolved: 10
  • ROUND 3 Must Fix: 0 · Should Fix: 3 · Nits: 2 · Resolved: 13

Findings

Must Fix

  1. view/render.go:153 Empty definition erases an existing template body
    Problem: Adding a name with an empty tree ({{ define "x" }}{{ end }} or a later {{ block "x" . }}{{ end }}) still calls AddParseTree, and html/template overwrites the body it holds for that name even though the text-level set keeps the old one.
    Impact: Make("x").Render() returns an empty string with a nil error, and First picks that empty name over a working fallback; reproduced with a page defining content and a later layout whose empty block "content" wipes it.
    Fix: Skip adding an empty tree when the name already has a renderable body.
    // before
    tree.Name = name
    if _, err := tmpl.AddParseTree(name, tree); err != nil {
    // after
    if parse.IsEmptyTree(tree.Root) && renderable[name] {
        return nil
    }
    tree.Name = name
    if _, err := tmpl.AddParseTree(name, tree); err != nil {

Should Fix

  1. view/view.go:124 Make ignores the names Exists accepts
    Problem: Exists cleans and normalizes the name (./pages/raw.tmpl, pages\raw.tmpl), but Make and First look up the raw string against slash-only parse keys.
    Impact: A view that Exists reports as present fails with ViewTemplateNotExist through Make and is silently skipped by First, notably for Windows-style separators.
    Fix: Normalize the name the same way Exists does before looking it up.

    // before
    func (r *View) Make(view string, data ...any) contractsview.Template {
        return NewTemplate(r, view, data...)
    }
    // after
    func (r *View) Make(view string, data ...any) contractsview.Template {
        return NewTemplate(r, normalizeFSPath(view), data...)
    }
  2. view/render.go:221 Empty placeholder names block a later source's real view
    Problem: contributed records every claimed name, including empty {{ block }} placeholders, and the loop copies them all into owned.
    Impact: A package's real view is shadowed by an app placeholder with the same name, so Exists on the package file is true but Make and First cannot render it.
    Fix: Only names that became renderable should take ownership.

    // before
    for name := range contributed {
        owned[name] = true
    }
    // after
    for name := range contributed {
        if renderable[name] {
            owned[name] = true
        }
    }
  3. view/template.go:54 Render map under-sized when shared data is large
    Problem: The merged map is allocated with len(r.data)+8, ignoring how many keys the shared data holds.
    Impact: With many shared keys every render rehashes and grows the map, adding avoidable allocations on the hot path.
    Fix: Track the shared key count and size the map from it.

    // before
    values := make(map[string]any, len(r.data)+8)
    // after
    values := make(map[string]any, len(r.data)+int(r.view.sharedCount.Load()))

Nits

  1. view/template.go:36 Exported template methods are undocumented
    Problem: Data, Name, Render and With on the exported *view.Template have no doc comments.
    Impact: go doc shows bare signatures for the type users get from Make, though the contract methods are documented.
    Fix: Add a short doc comment to each exported method.

  2. view/render.go:197 Unnamed boolean argument hides call intent
    Problem: claim takes an alias bool, and the call sites pass bare false / true.
    Impact: The real-name-versus-alias distinction that drives the naming rules is invisible at the call site.
    Fix: Name the argument at the call site.

    // before
    claim(sub.Name(), sub.Tree, false)
    // after
    claim(sub.Name(), sub.Tree, /* alias */ false)
  3. view/render.go:236 Variable holding an error is named named
    Problem: if _, named := template.New(name).Parse(text); named != nil binds the returned error to a variable called named.
    Impact: The name reads like a template value and makes this re-parse fallback harder to follow.
    Fix: Rename it to err.

    // before
    if _, named := template.New(name).Parse(text); named != nil {
        return named
    }
    // after
    if _, err := template.New(name).Parse(text); err != nil {
        return err
    }
  4. view/render.go:52 Redundant atomic store on every render
    Problem: The output size is stored back on every render even when it did not change.
    Impact: Every goroutine rendering the same view writes the same shared value, adding avoidable contention.
    Fix: Store only when the size changed.

    // before
    size.Store(int64(buf.Len()))
    // after
    if n := int64(buf.Len()); n != size.Load() {
        size.Store(n)
    }
  5. view/template_test.go:36 Same-kind source precedence is never tested
    Problem: No test gives two directories, or two filesystems, a view with the same name; only app-versus-dir and dir-versus-filesystem are pinned.
    Impact: Reordering sources() or breaking the owned bookkeeping would silently serve a package template over the app's.
    Fix: Register a second directory reusing an existing view name and assert the first source still wins.

    // before
    view.LoadViewsFrom(pkgDir)
    // after
    view.LoadViewsFrom(pkgDir)
    view.LoadViewsFrom(pkgDir2) // same name: pkgDir must still win

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.

Retrieve HTML output after make

2 participants