Skip to content

feat(view): stream component resources and JSON - #296

Draft
petehunt wants to merge 3 commits into
tokio-rs:mainfrom
petehunt:agent/view-resources
Draft

feat(view): stream component resources and JSON#296
petehunt wants to merge 3 commits into
tokio-rs:mainfrom
petehunt:agent/view-resources

Conversation

@petehunt

@petehunt petehunt commented Aug 3, 2026

Copy link
Copy Markdown

Depends on #292 and #295. Review commit e7d8a44 for this layer.

Motivation

Deferred views can stream HTML as soon as a component finishes, but component-specific CSS, JavaScript, and data still create a waterfall if they are only discovered in that completed fragment:

  1. render the component
  2. stream its HTML
  3. discover its assets
  4. begin fetching them

A component already knows its dependencies before slow rendering work begins. This PR lets it publish those requirements through the request context immediately, so the browser can fetch and execute a module while the component is still rendering.

API

A component requires external assets before its first slow await:

use topcoat::{
    Result,
    asset::{Asset, CxAssetExt, asset},
    context::Cx,
    view::{component, view},
};

const CHART_CSS: Asset = asset!("./chart.css");
const CHART_JS: Asset = asset!("./chart.js");

#[component]
async fn sales_chart(cx: &Cx) -> Result {
    cx.require_asset(CHART_CSS.stylesheet())?;
    cx.require_asset(CHART_JS.module())?;
    cx.send_json_named("sales-chart", &[12, 18, 15])?;

    let chart = load_chart().await?;
    view! { <div class="sales-chart">(chart)</div> }
}

The external module can start immediately and wait for its separately streamed data, regardless of which arrives first:

const points = await topcoat.json("sales-chart");

cx.send_json(&value) is also available when the key should be generated. It returns a JsonKey that can be rendered into the fragment, for example as a data-* attribute.

Streaming behavior

Resource and JSON requirements are request-scoped response events. The view response emits the shell first, then prioritizes queued events ahead of the fragment that requested them:

shell
stylesheet requirement -> <link rel="stylesheet">
module requirement     -> <script type="module">
JSON payload           -> topcoat.json(key)
completed view         -> deferred fragment patch

Events wake the response stream while deferred component futures remain pending. A view that queues events without deferred work also receives a streaming body, so the API is not limited to deferred components.

This builds on #292 and #295 without introducing spawned tasks. Deferred rendering remains structured: dropping the response body drops its pending component futures and request-scoped event queue.

Why requirements live on Cx

Attaching dependencies to a completed View would compose naturally, but the renderer could not discover them until the component returned. Publishing through Cx lets a component flush requirements before an expensive await while still returning an ordinary View that composes normally.

This diff intentionally supports only external stylesheets and JavaScript modules. Additional resource metadata or declarative view-attached assets can be explored separately without changing the streaming protocol.

Deduplication and JSON keys

Asset requirements are deduplicated by asset identity and resource kind on the server, then deduplicated again by the browser helper. Requiring one module or stylesheet from several concurrent or nested components inserts one DOM resource element.

Named JSON keys have deterministic conflict behavior:

  • the same key and serialized value is deduplicated
  • the same key with a different value is an error
  • application keys cannot use the reserved @topcoat/ namespace

Generated keys use a random response identifier plus a monotonic response-local counter inside that reserved namespace. They cannot collide with application-provided keys and are safe to retain across boosted page transitions.

topcoat.json(key) returns an already-resolved promise when the value has arrived, or a pending promise that resolves when its template is observed.

CSP and escaping

The response emits inert annotated <template> elements. The existing external defer_script() helper converts resource templates into external <link> and <script type="module"> elements and parses JSON templates. It does not generate inline executable scripts.

Resource URLs and keys are escaped as HTML attribute values. JSON is serialized on the server, escaped as HTML text, and parsed from the template content in the browser. Deployments still need their CSP to allow the external asset origin, but do not need unsafe-inline for this mechanism.

Coverage

  • proves resource and JSON events precede the fragment that requested them
  • covers event-only streaming without deferred views
  • covers server-side resource deduplication
  • verifies JSON cannot terminate its inert template
  • verifies generated-key uniqueness and namespace isolation
  • covers named-key deduplication and conflicting-value errors
  • documents the Rust and browser APIs

Validation

  • cargo +nightly fmt --all
  • cargo topcoat fmt (expected Leptos parse errors only)
  • cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
  • cargo test --workspace --all-features
  • RUSTDOCFLAGS="--cfg docsrs -Dwarnings" cargo +nightly doc --workspace --all-features --no-deps --locked
  • node --check crates/topcoat/browser/defer.js

AI disclosure

OpenAI Codex (GPT-5) helped design, implement, test, and prepare this change for review.

@petehunt
petehunt force-pushed the agent/view-resources branch from e7d8a44 to 0ea7e50 Compare August 6, 2026 21:44
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.

1 participant