Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c8f015a
fix(core): stop SSR instantiating a component named inside a comment
vivek7405 Jul 27, 2026
76d0afe
fix(core): tokenize contexts instead of searching for comment markers
vivek7405 Jul 27, 2026
2322906
test(bun): prove HTML-context handling matches across runtimes
vivek7405 Jul 27, 2026
6c0c156
docs: note that a comment in a template is inert, tags included
vivek7405 Jul 27, 2026
1c57837
fix(core): recover from an unbalanced quote, and skip tag interiors
vivek7405 Jul 27, 2026
19386c3
test(core): pin the quote and attribute-interior guards, correct the …
vivek7405 Jul 27, 2026
e2a21c0
fix(core): close the </ bogus-comment gap and cut the scan cost
vivek7405 Jul 27, 2026
fc17db1
fix(core): treat every text-only element as text, and pin the rest
vivek7405 Jul 27, 2026
5275008
test(core): pin the text-only set and both exclusions, sync the docs
vivek7405 Jul 27, 2026
f5554ca
test(core): pin where each text-only skip ENDS, and the streaming path
vivek7405 Jul 27, 2026
a46a6bd
fix(core): follow the spec on = and self-closing, pin six more guards
vivek7405 Jul 27, 2026
223f78c
fix(core): give unquoted attribute values their own state
vivek7405 Jul 27, 2026
66d64bc
fix(core): respect comments in element nesting and script double-escape
vivek7405 Jul 27, 2026
93a4276
fix(core): restore the nesting and double-escape source dropped by a …
vivek7405 Jul 27, 2026
33a48e8
fix(core): router fetches revalidate so a cached page cannot hide a d…
vivek7405 Jul 27, 2026
8e50703
fix(core): keep suspense close-search in full-string state, script <!…
vivek7405 Jul 27, 2026
0a5ac85
fix(core): the script dash-dash exit clears the escape from every state
vivek7405 Jul 27, 2026
eb8aad6
test(core): pin the double-escape half of the dash-dash exit
vivek7405 Jul 27, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ document.addEventListener('webjs:navigation-fallback', (e) => {

## Link Prefetch

Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff.
Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. Router fetches (navigation and prefetch alike) are sent with `cache: 'no-cache'`, so a page cached in the browser with a `max-age` is revalidated rather than replayed: the deploy check reads `x-webjs-build` / `x-webjs-src` off these responses, and a cached response would hand it pre-deploy ids and hide a deploy for the whole freshness window (#1131). With a stable page ETag the revalidation is answered with a cheap 304, so the cost is a conditional round-trip, not a re-download. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff.

Override per link with the `data-prefetch` attribute.

Expand Down
6 changes: 6 additions & 0 deletions .agents/skills/webjs/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ class Panel extends WebComponent({ label: String }) {

The full `<slot>` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite. A forwarded slot projects its content everywhere (client, SSR, hydration).

**A tag name inside an HTML comment is not instantiated.** `<!-- <my-card> is the wrapper -->` documents the template and renders no component, the same as in a browser. That holds for component tags, `<slot>`, and `<webjs-suspense>`. It extends to attribute values and to every element whose content the HTML parser reads as text rather than markup: `<script>`, `<style>`, `<iframe>`, `<xmp>`, `<noembed>`, `<noframes>`, `<plaintext>`, `<textarea>`, `<title>`. (Before #1128 a commented tag was constructed as a real element and ate the rest of the comment along with the markup after it, so an ordinary explanatory comment could silently delete part of the page.)

Two elements are deliberately excluded, and the second matters more. `<template>` content IS parsed and legitimately carries components, which is what Declarative Shadow DOM and streamed swaps rely on. `<noscript>` content is also parsed, because a browser with scripting disabled reads it as markup, and that is the case a progressive-enhancement framework exists to serve, so components inside `<noscript>` render normally.

Element nesting respects comments too: a comment holding the open or close tag of the element it sits inside (`<my-card>kid<!-- </my-card> --></my-card>`) rides along as content, and the element still ends at its real close tag. Script bodies follow the parser's double-escape rule, so the legacy `<!-- <script>... -->` wrapper pattern stays text to its true end.

```ts
class MyCard extends WebComponent {
render() {
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ jobs:
# Readable.fromWeb, which hangs on Bun).
- name: webjs compression on Bun
run: bun test/bun/compression.mjs
# SSR HTML-context handling on Bun (#1128): a tag name inside a comment,
# raw text, RCDATA, or an attribute value is text, not an element. The
# scanner is offset arithmetic over indexOf / startsWith results and a
# per-call RegExp, so a V8-versus-JSC divergence would not throw, it would
# shift a range boundary and make a component render on one runtime and
# silently vanish on the other. Asserts both directions.
- name: SSR comment and raw-text handling on Bun
run: bun test/bun/comment-not-an-element.mjs
# Dev hot reload on Bun (#514): start `webjs dev` under Bun, edit a
# re-imported route module, and assert the response updates with NO manual
# restart. The CLI re-execs under `bun --hot` on Bun (vs `node --watch` on
Expand Down
413 changes: 402 additions & 11 deletions packages/core/src/render-server.js

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions packages/core/src/router-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1674,7 +1674,16 @@ function prefetch(href) {
const headers = { 'x-webjs-router': '1', 'x-webjs-prefetch': '1' };
if (have) headers['x-webjs-have'] = have;

fetch(href, { method: 'GET', headers, credentials: 'same-origin' })
// `no-cache` (revalidate, NOT bypass) is load-bearing (#1131): the deploy
// check below reads x-webjs-build / x-webjs-src off this response, and a
// page served with a browser max-age would otherwise satisfy the fetch
// wholly from the HTTP cache, replaying pre-deploy ids. The check would then
// compare two equally stale values and skip the eviction, so a deploy stayed
// invisible for the whole freshness window plus one stale-while-revalidate
// serving per URL. With stable page ETags a forced revalidation is a
// conditional request answered 304, so the cost is a header round-trip, not
// a re-download.
fetch(href, { method: 'GET', headers, credentials: 'same-origin', cache: 'no-cache' })
.then(async (resp) => {
const ctype = resp.headers.get('content-type') || '';
if (!/^text\/html\b/i.test(ctype)) return;
Expand Down Expand Up @@ -2155,7 +2164,10 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
}

/** @type {RequestInit} */
const init = { method, headers, credentials: 'same-origin' };
// `no-cache` for the same reason as the prefetch fetch (#1131): applySwap
// hard-reloads on a build change it can only see if these headers are
// live, not replayed from the HTTP cache.
const init = { method, headers, credentials: 'same-origin', cache: 'no-cache' };
if (signal) init.signal = signal;
if (body != null && method !== 'GET' && method !== 'HEAD') init.body = body;

Expand Down
Loading
Loading