Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions content/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ layout: landing
<a class="sub" href="#pending">Pending state</a>
<a class="sub" href="#realtime">Multi-user</a>
<a href="#compare">How it compares</a>
<a href="#more">Everything else</a>
<a href="#more">More capabilities</a>
</aside>

<main class="doc">
Expand Down Expand Up @@ -206,7 +206,7 @@ func main() {
<h2>The HTML rule runs again in Go.</h2>
<p class="lead">Both halves are in the app above. The input carries <code>required</code>, and <code>ctx.ValidateForm()</code> re-runs exactly that rule on the server — a client that skipped it, scripting off or a direct POST, gets the same answer. Then <code>strings.EqualFold(name, "admin")</code> adds the rule HTML has no way to state.</p>

<p class="lead">The template side is the other two lines you read: <code>{{.lvt.AriaInvalid "name"}}</code> marks the field, <code>{{.lvt.ErrorTag "name"}}</code> is where the message lands. Returning an error from <code>Greet</code> is the whole mechanism — there's nothing to route. Scroll up and type <em>admin</em>, or try the smaller app here.</p>
<p class="lead">On the template side: <code>{{.lvt.AriaInvalid "name"}}</code> marks the field, <code>{{.lvt.ErrorTag "name"}}</code> is where the message lands. Returning an <a href="/reference/error-handling">error</a> from <code>Greet</code> is the whole mechanism — there's nothing to route. Scroll up and type <em>admin</em>, or try the smaller app here.</p>

<div class="demo">
<div class="demo-bar"><span class="dot"></span> greet-validate · the same pair, on its own</div>
Expand Down Expand Up @@ -310,7 +310,7 @@ func main() {
<div class="snip">
<div class="snip-label">app.go · the server can start the same cycle</div>
<pre class="language-go"><code class="language-go">sess.TriggerAction("ServerRefresh", nil)</code></pre>
<p class="note">You already read the <code>WithTopicACL</code> in <code>main</code> that admits <code>"wall"</code> — developer topics are deny-all until you name one. This is the same publish path with no user action behind it: the "the server said hi at …" line in the cards above, pushed on a timer.</p>
<p class="note">You already read the <a href="/reference/pubsub"><code>WithTopicACL</code></a> in <code>main</code> that admits <code>"wall"</code> — developer topics are deny-all until you name one. This is the same publish path with no user action behind it: the "the server said hi at …" line in the cards above, pushed on a timer.</p>
</div>

</section>
Expand All @@ -334,14 +334,14 @@ func main() {
</section>

<section id="more" class="step">
<div class="eyebrow">Everything else</div>
<h2>The rest of the docs covers the ordinary screens.</h2>
<p class="lead">Admin panels, internal tools, CRUD, dashboards, approvals, uploads, auth, and the occasional shared view. That is what this is for.</p>
<div class="eyebrow">More capabilities</div>
<h2>Your app has file uploads and a login. Both are here.</h2>
<p class="lead">Admin screens, internal tools, CRUD and dashboards are the point.</p>
<div class="links">
<a href="/reference/uploads"><span class="link-t">File uploads</span><span class="link-g">live progress, same app</span></a>
<a href="/reference/pubsub"><span class="link-t">Shared views</span><span class="link-g">Subscribe &amp; Publish</span></a>
<a href="/recipes/login/"><span class="link-t">Auth &amp; login</span><span class="link-g">form-based sessions, no middleware</span></a>
<a href="/reference/session"><span class="link-t">Sessions &amp; state</span><span class="link-g">scoped per browser or user</span></a>
<a href="/reference/error-handling"><span class="link-t">Forms &amp; errors</span><span class="link-g">Go errors, same template</span></a>
<a href="/reference/navigate"><span class="link-t">Navigation</span><span class="link-g">move between pages, no reload</span></a>
<a href="/cli/"><span class="link-t">Scaffolding</span><span class="link-g">generate common app shapes</span></a>
<a href="/client/"><span class="link-t">Browser client</span><span class="link-g">transport &amp; DOM patching</span></a>
<a href="/guides/observability"><span class="link-t">Observability</span><span class="link-g">metrics &amp; tracing hooks</span></a>
Expand Down
44 changes: 44 additions & 0 deletions examples/upload-modes/upload-modes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
Expand Down Expand Up @@ -57,6 +58,45 @@ func newChromiumCtx(t *testing.T) context.Context {
return ctx
}

// waitClientReady blocks until the client bundle has loaded and bound its
// listeners. Every upload here is driven by a change event on an
// input[lvt-upload], and the input is server-rendered: it is visible in the
// initial HTML, long before the deferred bundle loads. Firing the change event
// in that gap drops it silently, no upload starts, and the WaitVisible on the
// result element then burns the whole 60s context deadline — which is what
// upload-modes_test.go:119 kept failing with in CI.
//
// isReady() is the right signal rather than the weaker "client object exists"
// check used elsewhere in the repo: measured against this app with the socket
// killed, the object appears after ~2ms and isReady() only goes true at ~640ms.
// It resolves on the HTTP-fallback path too, so the WS-disabled tests can wait
// on the same condition.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Follow-up worth filing, deliberately not done here.

isReady() is the strong signal. Three other suites in this repo wait on the weaker typeof window.liveTemplateClient !== 'undefined':

  • examples/chat/chat_e2e_test.go:89
  • examples/live-preview/live_preview_test.go:273
  • examples/seat-picker/seat_picker_test.go:144

Measured against the upload-modes app with the socket killed, that condition goes true at 1.8ms while isReady() goes true at 637ms. So those three clear the gate ~635ms early and carry the same latent race this commit fixes — they just haven't lost it in CI yet.

Left alone because they're passing and it's outside this PR's scope. Happy to do it as its own change.

func waitClientReady(timeout time.Duration) chromedp.Action {
return chromedp.ActionFunc(func(ctx context.Context) error {
start := time.Now()
for {
var ready bool
if err := chromedp.Evaluate(
`!!(window.liveTemplateClient && window.liveTemplateClient.isReady && window.liveTemplateClient.isReady())`,
&ready,
).Do(ctx); err != nil {
return fmt.Errorf("evaluate client readiness: %w", err)
}
if ready {
return nil
}
if time.Since(start) > timeout {
return fmt.Errorf("client not ready after %v", timeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
})
}

// newUploadModesApp clears the storage dirs, starts the example server, and
// returns it with its base URL wired into the controller's presigner.
func newUploadModesApp(t *testing.T) (*httptest.Server, *UploadModesController) {
Expand Down Expand Up @@ -109,6 +149,7 @@ func TestUploadModes_E2E(t *testing.T) {
if err := chromedp.Run(ctx,
chromedp.Navigate(srv.URL),
chromedp.WaitVisible(`input[lvt-upload="proxied"]`, chromedp.ByQuery),
waitClientReady(20*time.Second),
// Set the record id before selecting the file: the client serializes it
// into the multipart POST ahead of the file part, and OnUpload reads it.
chromedp.SetValue(`#proxied-record-id`, "invoice-42", chromedp.ByQuery),
Expand Down Expand Up @@ -189,6 +230,7 @@ func TestUploadModes_ProxiedWSDisabled_E2E(t *testing.T) {
}),
chromedp.Navigate(srv.URL),
chromedp.WaitVisible(`input[lvt-upload="proxied"]`, chromedp.ByQuery),
waitClientReady(20*time.Second),
// The form field rides the multipart POST on the HTTP-fallback path too.
chromedp.SetValue(`#proxied-record-id`, "offline-9", chromedp.ByQuery),
chromedp.SetUploadFiles(`input[lvt-upload="proxied"]`, []string{img}, chromedp.ByQuery),
Expand Down Expand Up @@ -232,6 +274,7 @@ func TestUploadModes_DirectWSDisabled_E2E(t *testing.T) {
}),
chromedp.Navigate(srv.URL),
chromedp.WaitVisible(`input[lvt-upload="direct"]`, chromedp.ByQuery),
waitClientReady(20*time.Second),
chromedp.SetUploadFiles(`input[lvt-upload="direct"]`, []string{img}, chromedp.ByQuery),
// #direct-result only renders after the HTTP upload_complete handshake
// reconstructs the entry and UploadDirectComplete sets the ref.
Expand Down Expand Up @@ -272,6 +315,7 @@ func TestUploadModes_VolumeWSDisabled_E2E(t *testing.T) {
}),
chromedp.Navigate(srv.URL),
chromedp.WaitVisible(`input[lvt-upload="volume"]`, chromedp.ByQuery),
waitClientReady(20*time.Second),
chromedp.SetUploadFiles(`input[lvt-upload="volume"]`, []string{img}, chromedp.ByQuery),
chromedp.WaitVisible(`#volume-result`, chromedp.ByQuery),
chromedp.Text(`#volume-result`, &volumeText, chromedp.ByQuery),
Expand Down
Loading