diff --git a/.agents/skills/webjs/references/muscle-memory-gotchas.md b/.agents/skills/webjs/references/muscle-memory-gotchas.md index bb1cf6f2e..2c0f22fb1 100644 --- a/.agents/skills/webjs/references/muscle-memory-gotchas.md +++ b/.agents/skills/webjs/references/muscle-memory-gotchas.md @@ -43,6 +43,25 @@ const users = await getUsers(); There is no React `cache()`, `use()`, or `unstable_cache`. Caching is the `cache()` query helper, `export const revalidate` on a page, or `export const cache` on a GET action. +### A function in `
`, host); } + catch (e) { threw = e; } + assert.ok(threw, 'render must throw'); + assert.ok(/function was interpolated into action=/.test(threw.message), threw.message); + assert.ok(!document.body.innerHTML.includes('BROWSER_LEAK_MARKER'), 'no source in the document'); + }); + + test('.action=${fn} property binding cannot reflect the source into the attribute', () => { + // THE case linkedom cannot see. Without the guard, `el.action = fn` here + // reflects the stringified function into the live `action` attribute. + const host = mount(); + let threw = null; + try { render(html``, host); } + catch (e) { threw = e; } + assert.ok(threw, 'render must throw'); + assert.ok(!document.body.innerHTML.includes('BROWSER_LEAK_MARKER'), 'no source in the document'); + const form = host.querySelector('form'); + if (form) { + assert.ok(!String(form.getAttribute('action') || '').includes('BROWSER_LEAK_MARKER'), 'no source in the action attribute'); + assert.ok(!String(form.action || '').includes('BROWSER_LEAK_MARKER'), 'no source on the action property'); + } + }); + + test('a refused re-render leaves the previously rendered action intact', () => { + const host = mount(); + const tpl = (a) => html``; + render(tpl('/submit'), host); + const before = host.querySelector('form').getAttribute('action'); + assert.equal(before, '/submit'); + + let threw = null; + try { render(tpl(secretAction), host); } catch (e) { threw = e; } + assert.ok(threw, 'the re-render must throw'); + assert.equal(host.querySelector('form').getAttribute('action'), '/submit', 'prior value survives'); + assert.ok(!document.body.innerHTML.includes('BROWSER_LEAK_MARKER'), 'no source in the document'); + }); + + test('a real form still submits to a string action', () => { + // The guard must not disturb the ordinary case: a string action has to + // survive into a working, submittable form. + const host = mount(); + render(html``, host); + const form = host.querySelector('form'); + assert.equal(form.getAttribute('action'), '/feedback'); + assert.equal(form.method, 'post'); + assert.equal(new FormData(form).get('msg'), 'hi'); + }); + +}); diff --git a/packages/core/test/rendering/form-action-attr-guard-client.test.js b/packages/core/test/rendering/form-action-attr-guard-client.test.js new file mode 100644 index 000000000..646b56ea4 --- /dev/null +++ b/packages/core/test/rendering/form-action-attr-guard-client.test.js @@ -0,0 +1,87 @@ +// #1154, client half: the browser renderer refuses the same commit, so a +// client re-render can never write a function's source into the live DOM +// (`applyPart`'s 'attr' and 'attr-mixed' cases). Runs under linkedom. +import { test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseHTML } from 'linkedom'; + +before(() => { + const { window } = parseHTML(''); + globalThis.document = window.document; + globalThis.DocumentFragment = window.DocumentFragment; + globalThis.Node = window.Node; + globalThis.Element = window.Element; + globalThis.Comment = window.Comment; + globalThis.Text = window.Text; + globalThis.NodeFilter = window.NodeFilter; + globalThis.HTMLElement = window.HTMLElement; +}); + +let html, render; +before(async () => { + ({ html } = await import('../../src/html.js')); + ({ render } = await import('../../src/render-client.js')); +}); + +async function fakeAction() { const S = 'CLIENT_SECRET'; return S; } + +// NOTE on what these DON'T assert: `createInstance` applies every part before +// it calls `container.replaceChildren(...)`, so a throwing part leaves `host` +// empty no matter what. An `assert.ok(!host.innerHTML.includes(SECRET))` here +// would pass unconditionally and prove nothing. The load-bearing assertion is +// the throw itself, plus the SECOND-render cases below, where the host DOES +// hold a live form and a leak would therefore be observable. +test('client render of action=${fn} throws', () => { + const host = document.createElement('div'); + assert.throws( + () => render(html``, host), + /function was interpolated into action=/, + ); +}); + +test('client render of mixed action="/x/${fn}" throws', () => { + const host = document.createElement('div'); + assert.throws( + () => render(html``, host), + /function was interpolated into action=/, + ); +}); + +// Re-render over an ALREADY-MOUNTED form. Here the host really does hold a live +// element, so if the guard let the value through, the source would be sitting +// in the DOM and this would catch it. +test('re-render swapping a string action for a function throws, live DOM stays clean', () => { + const host = document.createElement('div'); + const tpl = (a) => html``; + render(tpl('/ok'), host); + assert.equal(host.querySelector('form').getAttribute('action'), '/ok'); + assert.throws(() => render(tpl(fakeAction), host), /function was interpolated into action=/); + assert.ok(!host.innerHTML.includes('CLIENT_SECRET'), 'live DOM must not carry the source'); + assert.equal(host.querySelector('form').getAttribute('action'), '/ok', 'the prior value must survive'); +}); + +// `.action=${fn}` is a PROPERTY binding, a different commit site from the +// attribute one. `action` is a reflected IDL attribute, so assigning a function +// writes its source into the element's own `action` in a real browser. +test('client .action=${fn} property binding on a native form throws', () => { + const host = document.createElement('div'); + assert.throws( + () => render(html``, host), + /function was interpolated into action=/, + ); +}); + +test('a custom element keeps accepting a function on .action', () => { + // Not a reflected IDL attribute, just an author-defined property, so passing + // a function is legitimate and must not be refused. + const host = document.createElement('div'); + render(html`Cause: WebJs instruments the native DOM mutation methods on every light-DOM host to keep the slot API live, and it installs those interceptors as own properties ON THE ELEMENT INSTANCE. An own instance property shadows a method defined on the class prototype, so the framework's interceptor wins and your same-named class method is never reached. The instrumented names are append, prepend, before, after, replaceWith, replaceChildren, remove, appendChild, insertBefore, removeChild, and replaceChild. TypeScript does not catch it because a shorter override (zero or one argument) is assignable to the native signature.
Fix: rename the member to a non-native name (appendRow(), removeItem()) and update its call sites. Only these native mutation members are affected; render() and the lifecycle hooks are meant to be overridden. The no-shadowed-native-member check rule catches this ahead of time (it flags only a real instance method, never a static member, a nested-object property, or a static shadow = true component, whose native shadow slots are not instrumented). See Components.
action=Symptom: a page or component throws at render with a function was interpolated into action= on <form>, usually right after binding a server action to a form the way Next.js does it.
Cause: during SSR an imported 'use server' action is the REAL function (the RPC stub exists only in the browser), and action= is an ordinary attribute hole, so stringifying it would write the action's whole body, including anything its closure shows such as a connection string, into the HTML every visitor downloads. WebJs refuses rather than emitting it. The same applies to formaction= on a submit button, to every hole shape (action=\${fn}, action="\${fn}", action="/x/\${fn}"), and to the .action=\${fn} property form on a native form, where the reflected IDL attribute would carry the source into the DOM.
Fix: omit action so the form posts to the page's own URL, and handle the submission in that page's action export (write <form method="post"> rather than action="", which the HTML spec treats as a conformance error since the attribute must be a non-empty URL when present). A string action is unaffected. See Server Actions and Progressive Enhancement.
static properties is no longer supportedSymptom: a component throws at construction with static properties is no longer supported. Declare reactive properties via the factory instead.
Cause: the class body has a hand-written static properties = { ... } field. WebJs declares reactive properties only through the WebComponent({ ... }) base-class factory now, and the runtime throws on a direct static properties.