Semantics
Vue's semantics are the reference wherever every target can meet them. Where a target cannot, the contract states the guarantee that every target keeps, and the capability matrix declares the difference. Each rule has corpus cases that the tests run on all seven targets: the server's HTML, the DOM, the accessibility tree, the pixels and the trace of every interaction must match Vue's.
This is version 1, as milestone M2 built it.
| Rule | Guarantee | Cases |
|---|---|---|
| Setup runs once | The setup runs once per instance; a const keeps its first value | semantics/setup-once, semantics/props-seed-state, semantics/props-seed-let, semantics/helper-order |
| Reactive props | The template, getters and client code read a prop's current value | semantics/reactive-props, semantics/derived-from-props, semantics/late-optional-prop |
| Read after write | A read of state after a write in client code sees the new value | semantics/read-after-write, semantics/nested-call-bubbling |
| Deferred reads | Code that runs later reads the latest state and props | semantics/deferred-reads, events/listener-pairs |
| Derived consistency | A computed read right after a write of what it reads is up to date | semantics/derived-consistency |
| Watchers | A watcher calls back once per run of client code, on a change, with the previous value | semantics/watch-timing, effects/watch-identity, effects/async-watchers, effects/async-overlap |
| Effects are client-only | No effect and no hook changes what the server renders | semantics/effects-client-only |
| watchEffect | watchEffect runs after the first render, and after a change of what it reads | effects/watch-effect, effects/persistence-and-fetch |
| Teardown | Every cleanup runs before the onUnmounted hooks | lifecycle/teardown-order |
| nextTick | await nextTick() resumes once the DOM shows the writes before it | lifecycle/next-tick, effects/pre-and-post |
| Emit | emit returns nothing, and its listeners may run later | semantics/emit |
| Events | A listener hears the DOM's event of its name, with its options | events/form-events, events/event-options, events/listener-order, events/focus-and-change |
| Keyed lists | Not guaranteed yet: an item's element may be rebuilt when the list is reordered | none |
| Determinism | Rendering is a pure function of props and state | diagnostics/nondeterministic-render |
| Prevent default | preventDefault() and stopPropagation() act while the event is dispatched | semantics/prevent-default, events/guard-controls, events/keyboard-navigation |
The cases are in tests/integration/cases.
Setup runs once
The setup runs once per instance on every target. A const is evaluated once and keeps its first value, so one that reads a prop does not follow it (UF2007 warns). State seeded from a prop, ref(initial), starts with the prop's value at creation and keeps its own value when the prop changes.
- React runs the component's body on every render. A constant that reads the component's values is a
useStatewhose initial value React reads once, and one that reads nothing moves to the module. A state's initial value that calls or constructs is passed as a function,useState(() => …), so that React evaluates it once. - Svelte and Solid read a prop or a state in the setup through
untrack. - Angular sets an input after it constructs the class, so a field initialiser would read an optional input's default, and throw for a required one. State seeded from an input is a
linkedSignal, and a constant that reads one acomputed, each read once inngOnInit, after the inputs are set, throughuntracked. A setupletseeded from an input is assigned there too, in source order. A function passed as a value is an arrow field, declared before the setup's state, derived values and constants, so an initial value can call it. - Qwik runs the component's body again when a signal it reads changes, and its optimiser reads a destructured prop as the current prop. So a constant that reads a prop, a state or a derived value is a
useConstant.useSignalreads its initial value once. Qwik keeps the watchers and hooks in source order, and moves up a declaration that one of them reads before the source declares it. - Astro renders each request as a new instance, so state never survives a new render there. A test that checks state across a rerender declares that it requires
interactivity, which Astro does not support.
Qwik's useConstant evaluates its function again while the value is null or undefined: a constant whose first value is nullish follows what it reads until it holds a value.
Reactive props
A destructured prop read in the template, a computed, a watcher's getter or client code reads the prop's current value, on every target, as in M1. Read in the setup, ref(initial), it gives the value at creation (Setup runs once).
- Solid reads every prop as
props.name, and Angular asthis.name(). - React reads a prop that deferred code reads through a mirror (Deferred reads).
- A watcher watches a prop through a getter,
watch(() => celsius, …)(UF2020).
Qwik's useComputed$ and tasks follow only the props the parent passed when they first read them. So on Qwik a derived value or a watcher misses an optional prop that the parent passes only after the mount, through a spread that gains the key, though the template sees it (late-prop, a warning). Pass the prop from the start, undefined while it has no value.
Read after write
A read of state after a write, in client code, sees the new value: in the same function, in a function it calls, in another listener of the same event, after an await, and in code that runs later.
function addTwice() {
count.value++;
count.value++;
emit("total", count.value);
}
React's state changes only at its next render, so React keeps a mirror ref of every state that client code writes. It writes the mirror before each setter call, and every piece of client code reads the mirror; the template reads the state:
function addTwice() {
countRef.current++;
setCount(countRef.current);
countRef.current++;
setCount(countRef.current);
onTotal?.(countRef.current);
}
- Vue's and Svelte's state, Solid's signals, Angular's signals and Qwik's signals apply a write at once.
- React writes
??=,||=and&&=out,x = x ?? y, and a++of a local that a nested function captures as+= 1, because the React Compiler bails out on both. - Qwik makes a local function that client code calls a
$()function. A call of it that is a statement, or whose value is used, is awaited, so a read after the call sees its writes. Where a call must not wait, in a listener that another listener of the event follows or in a task, Qwik writes the function's statements in place. A call of a function that returns a promise is that promise, awaited only where the source awaits it.
Deferred reads
Code that runs later, after an await, in a timer's callback or in a callback a hook schedules, reads the latest state and props, never a render's snapshot.
async function requestDelete() {
confirming.value = true;
await new Promise<void>((resolve) => {
resolveConfirmation = resolve;
});
confirming.value = false;
emit("deleted", fileName, copies.value);
}
While the deletion waits for its confirmation, the user adds a copy and the parent renames the file. The continuation emits the new name and the new count, on every target. React's closure would hold the render's values, so React reads a prop and an event's listener that deferred code reads through mirrors, synced after every render:
const fileNameRef = useRef(fileName);
const onDeletedRef = useRef(onDeleted);
useLayoutEffect(() => {
fileNameRef.current = fileName;
onDeletedRef.current = onDeleted;
});
const [copies, setCopies] = useState(1);
const copiesRef = useRef(copies);
const [confirming, setConfirming] = useState(false);
const confirmingRef = useRef(confirming);
const resolveConfirmation = useRef<(() => void) | undefined>(undefined);
async function requestDelete() {
confirmingRef.current = true;
setConfirming(confirmingRef.current);
await new Promise<void>((resolve) => {
resolveConfirmation.current = resolve;
});
confirmingRef.current = false;
setConfirming(confirmingRef.current);
onDeletedRef.current?.(fileNameRef.current, copiesRef.current);
}
The other targets read their props and state live: Vue's, Svelte's and Solid's props, Angular's input signals, and Qwik's props, which its optimiser reads from the props object.
A local function that client code passes as a value, to addEventListener or a timer, keeps one identity for the instance's life, so the listener one handler adds is the one another removes. React declares it once, useState(() => …), and it reads props and state through the mirrors. Qwik creates a component's $() functions again each time it renders, so it holds such a function in a useConstant.
Derived consistency
A computed read in client code right after a write of what it reads is up to date, on every target. React's useMemo gives the value of the last render, so client code reads a getter over the mirrors instead, currentTotal():
const total = useMemo(() => seats * pricePerSeat, [seats, pricePerSeat]);
function currentTotal() {
return seatsRef.current * pricePerSeat;
}
const summary = useMemo(
() => (seats === 1 ? `1 seat for ${total}` : `${seats} seats for ${total}`),
[seats, total],
);
function currentSummary() {
return seatsRef.current === 1
? `1 seat for ${currentTotal()}`
: `${seatsRef.current} seats for ${currentTotal()}`;
}
function addSeat() {
seatsRef.current += 1;
setSeats(seatsRef.current);
onQuote?.(seatsRef.current, currentTotal(), currentSummary());
}
- Vue's, Angular's and Svelte's derived values recompute when they are read. Solid's
createMemorecomputes as each write is made, and Qwik'suseComputed$when what it reads is written. - React prints a
useMemoonly for acomputedthat the template, a getter, a watcher's source orwatchEffectreads. One that only client code reads is its live getter alone. - React's
useMemoand Solid'screateMemoevaluate a getter when the component renders, even when no code reads it. So a getter gives a value for every state the component can reach, and reads only what is declared before it (UF2023).
Watchers
A watcher's callback runs after the writes that trigger it, before the interaction settles. It runs only when a source's value changed by Object.is (any element, for an array source): a value written away and back calls nothing back. Its previous is the source's value at the watcher's last callback, or at its creation; it is undefined on an immediate first run, and [] for an array source. Its onCleanup callbacks run right before its next callback and when the component is removed, never on a run that calls nothing back. A callback that awaits does not hold the watcher: a change while it waits runs its cleanups and calls back again at once.
Writes within one synchronous run of client code trigger a watcher once at most, whatever blocks, guards and loops the run holds. Writes separated by an await, or, on Qwik, by a call of a local function, may trigger it once or twice: code must not depend on whether they coalesce. await nextTick() always separates them. The order of different watchers that one run triggers is not part of the contract: Vue and Solid order them by trigger, Svelte and React by declaration. So the tests record each step's emits grouped by event, and check the order of emits only within one synchronous run.
- Vue copies the watcher, and reads a state that holds an object or an array (a
shallowRef) through a getter,watch(() => selected.value, …): Vue'swatchcalls a shallow ref's watcher back on every run, changed or not. - React runs each watcher in an effect over its source, with the previous value in a ref and the callback in an effect event. One handler's writes make one render, so the effect runs once. React has no phase before the render, so a watcher without
flush: "post"is an effect after it too: where one writes state, the post watchers andwatchEffectwait until what it wrote has rendered, as Vue runs them after its pre watchers' writes. - Svelte's
$effect.preruns once after a synchronous run, and compares withObject.is. - Solid runs what a write triggers as the write is made, so a watcher goes through
createWatcher, a scheduler as Vue's:createReactionqueues the watcher when a value it read is written, and a microtask queued at the first write runs each queued watcher once, those before the DOM updates first, comparing withObject.is. Client code is copied as written, with nobatch.watchEffectgoes throughcreateWatchEffect, over the values it reads. Writes made while Solid mounts the component, such as an immediate watcher's during the setup, call the watchers back after theonMountedhooks, where Vue calls them before. - Angular's effects run in its change detection, once for one handler's writes, over a
computedof the source. - Qwik's
useTask$runs once for one handler's writes; a call of a local function is a lazy call that Qwik awaits, so writes on each side of it may call back twice. An async callback's body goes on by itself,void (async () => {…})(): Qwik runs a task again only once its last run has settled.
Effects are client-only
No watcher without immediate, no watchEffect and no lifecycle hook runs during the server's render, and no effect changes what the server renders. An immediate watcher's first callback runs during the setup on Vue, on the server too, and may on Qwik, so it is held to what the server can run (UF2013).
- Vue writes
watchPostEffectforwatchEffect. A watcher withoutimmediate, a post effect and the hooks never run in Vue's server setup. - Svelte writes
$effectandonMount, and neveronDestroy, which its server runs. - Solid writes
onUnmountedasonMount(() => onCleanup(…)): a plainonCleanupruns when the server disposes its render. - Angular writes
afterNextRenderandafterRenderEffect, which never run on the server, creates watchers inngOnInitwith their immediate callback behind a browser check, and runsonUnmountedinngOnDestroy, behind a browser check too: Angular destroys a component on the server after it renders. - Qwik writes
useVisibleTask$, which runs in the browser only, for hooks,watchEffectand post watchers. Once Qwik resumes a server-rendered page, it runs a task again only when a value it tracks changes, so an immediate watcher's first callback has then run on the server only. Resuming is checked from M8.
watchEffect
watchEffect runs after the first render, and again after any prop, state or derived value it read changes. It runs after the DOM updates on every target. Its dependencies are the reactive values it reads in the straight-line start of its body (UF2015): React, Solid and Qwik list them before the effect runs, where Vue tracks what each run reads. What it hands on to run later, a timer's callback, a promise's continuation, a listener or an onCleanup callback, is no dependency on any target. A DOM read in a watcher needs flush: "post" (UF2018); watchEffect needs nothing. An effect that writes a state it reads while it runs would run itself again on every target but Vue (UF2027).
- Qwik may run a visible task twice for one change, so its
watchEffectkeeps the values it last ran for, and runs only when one changed byObject.is. - React's
watchEffectwaits, as a post watcher does, until what the pre watchers wrote has rendered, and then runs only with values its last run did not have.
Teardown
When a component is removed, every watcher's and watchEffect's cleanups run first, then the onUnmounted hooks, in source order, on every target: Vue stops a component's effects before it calls its hooks. The order among the cleanups of different watchers is not part of the contract.
- React declares the
onUnmountedeffects after every other effect, and React tears effects down in declaration order. - Svelte prints an
onUnmountedhook declared before a watcher with a cleanup after it. - Solid disposes a component's computations in the reverse of their creation, so it writes the hooks before the first watcher, in reverse order: they run after every cleanup, in their order.
- Angular destroys the watchers and the effects that register a cleanup in
ngOnDestroy, then runs the hooks. - Qwik registers the hooks' visible tasks after every other task.
Teardown code reads no template ref, and does not wait (UF2026): Vue and React empty a ref before it runs, and Vue drops an emit once the component is unmounted.
nextTick
await nextTick() resumes once the DOM shows the writes made before it, and what the watchers they trigger write. A pending nextTick() resolves when its component is removed too.
- Vue writes
nextTick, and Sveltetick. - Solid applies a write to the DOM as it makes it, and its watchers run in a microtask queued at the first write, so its
nextTickhelper resolves a promise after them. - React's
useNextTickasks for a render of its own and resolves once React has rendered it, run its effects, and rendered what the component's watchers wrote. - Angular's
nextTickmethod renders the pending change itself in a microtask,ApplicationRef.tick(), and resolves after it. - Qwik renders in a microtask, so its
nextTickhelper resolves after the next task.
On Vue, Svelte, Solid and Angular the code after await nextTick() runs in the task that wrote, before the next event. React and Qwik may resume in a later task (Outside the contract).
Emit
emit returns nothing, and code never depends on a listener having run: Qwik's listeners are lazy functions that may run after the emit. So an emit is a statement of its own (UF2017). Within one synchronous run, a component's emits reach the listeners in order.
Code emits nothing once its component is removed: Vue drops such an emit, Angular drops it with a warning, and the other targets deliver it. Angular destroys a watcher and a watchEffect in ngOnDestroy, before it stops the outputs, so that a cleanup that emits reaches its listener there as on Vue.
Events
A listener hears the DOM's event of its name, with its option (Events): change on a text field when it commits, focus and blur without bubbling, a capture listener before the target's and a bubble listener after, a once listener once for each element, and a passive listener without preventDefault() (UF3034). The listeners of one event and phase on one element run in attribute order.
- React listens natively where its synthetic event differs from the DOM's, and keeps
oncewith a guard (event-semanticsandevent-once, emulated). It runs an element's listeners of one event and phase in one handler, in attribute order; an async one is called and left to finish, so every later one starts in the dispatch. - Svelte runs a
oncelistener's handler through an inline guard,once, set when the handler first runs, so an event that a descendant stopped does not use it up (event-once, emulated). - Solid listens natively to an event it would delegate when the component also listens to it natively, with
onceorpassiveor a handler typed as the DOM types the event, so the two run in the DOM's order. An element with several listeners of one event and phase adds them all from its ref callback, in attribute order. - Angular keeps the options through attribute directives (emulated). Where an element has several bubble listeners of one event, they are one template listener that chains their statements in attribute order, a
onceone behind a guard,once(clickOnce, $event) && …. - Qwik runs a capture listener from the window when an element of the component listens to the same event in both phases. Such a listener's
preventDefault()orstopPropagation()cannot run at its element's place, so Qwik reports it (conditional-event-control). Where the component also listens to an event withoutpassive, Qwik's passive listener of that event is not passive: Qwik's loader runs passive and other handlers in two passes over the path. An element's listeners of one event are one handler that runs them in order, and a listener that another listener of the event follows writes its calls of local functions in place, so it is done when the next one starts.
Keyed lists
Keeping an item's element when a keyed list is reordered is not guaranteed yet: Solid keys its items by reference, and Astro has no client. It lands in M8. So a case never reorders a list, or replaces a row's item, while focus is inside the list.
A conditional that switches branches replaces the branch's elements on every target, so focus and what was typed leave with them (semantics/nested-branches, state/narrowed-reads). React would reuse an element of the same tag in the same place. So where a branch holds state a user can change, a control, a link, a listener or a template ref, and two branches can render an element of one tag at their top, React keys every branch:
{confirming ? (
<button key={0} type="button" onClick={confirmDelete}>
Confirm the deletion
</button>
) : (
<button key={1} type="button" onClick={requestDelete}>
Delete
</button>
)}
Determinism
Rendering is a pure function of props and state: a template, a getter, an initial value and the functions they call read no clock, no chance and no locale (UF3019), change nothing (UF3021, UF2011) and call only pure local functions (UF2014). A value from the clock or the machine is passed in as a prop, or written to state from client code.
Prevent default
preventDefault() and stopPropagation() act while the browser dispatches the event, on every target. A call after an await, or in code the handler hands on, such as a timer's callback, comes too late on every target (UF3033). Every other call acts where it stands: under a test, in a switch case, beside other statements or after a guard clause. The tests fail a form submission or a link's navigation that a component does not prevent.
Qwik runs a handler after the event, so it runs the calls apart, while the event is dispatched (Events). A call at the top of a listener, on every event, is a preventdefault: or stoppropagation: attribute. A call there under a test of the event alone, or after guard clauses on the event, runs in a sync$ handler before the listener's own. Qwik cannot run any other call at dispatch, and reports it (conditional-event-control, an error): events/keyboard-navigation has no Qwik output.
Outside the contract
The outputs behave the same for every value and every sequence of actions that the rules above cover, with these exceptions. Avoid them: the compiler cannot see them.
- Writing
NaNoverNaN, or-0over0. Vue compares values withObject.is, and Svelte's state and Solid's and Qwik's signals with===: there,NaNoverNaNruns awatchEffectthat reads it again, and-0over0calls back no watcher, where Vue's does. - Code that depends on whether two writes separated by an
awaitcall a watcher back once or twice, or on the order of two watchers. Writes in two tasks with no render between them, such as two timers' callbacks, may call a watcher back once on React, Angular and Qwik, which render in a task of their own.await nextTick()between the writes separates them. - On Solid, the order of the
onMountedhooks and a watcher that a write during the setup triggers, such as an immediate watcher's: Solid calls the watcher back after the hooks, Vue before them. - On Solid, the states between two writes of one synchronous run. Solid renders each write as it is made, where Vue renders the run's last state. So a template or a getter that throws on such a state (a list shrunk before its selected index moves) stops the run half-written, and a run that hides an element and shows it again creates it anew, without its focus or what was typed. Write in an order whose every state renders.
- Code that depends on a listener having run when
emitreturns. - An emit after the component is removed.
- Where code after
await nextTick()runs. It runs in the task that wrote on Vue, Svelte, Solid and Angular, and on React after a click's or a key's handler. React resumes in a later task after other writes (a watcher's, a hook's, a timer's, or code's after anawait) and where the component's watchers write in response, and Qwik always does. There, the next event of the same input may run first: Enter'skeypressafter itskeydown, which activates a focused button. Act on the event that ends the gesture, or prevent the key's default. nextTick()on Qwik after a render that takes longer than about 15 milliseconds: Qwik yields, and the promise may resolve before the DOM shows the writes.- Code that depends on an async listener's continuation running before a later listener of the same event. On Vue, the browser runs the microtasks between two listeners, so a continuation after an
awaitthat resolves at once may run first. React, Svelte and Solid run the template's listeners from one delegated listener, and Angular, React and Qwik run an element's listeners of one event in one handler, so the continuation runs after the later listener. Do what a later listener needs before the firstawait. - Mixing a template listener and a listener that client code adds with
addEventListenerwhere their order or astopPropagation()between them matters. The added listeners run in the DOM's order. React, Svelte and Solid run the template's listeners from a delegated listener at their root (Solid at the document): a template listener'sstopPropagation()cannot stop a listener added to an ancestor or the document, and an element's own added listener may run before a descendant's template listener. Qwik runs every template listener from the document's capture phase, before any listener added to an element, the target's own included: a template listener'sstopPropagation()keeps the event from them all, and theirs stops no template listener. Listen to such an event in the template only, or withaddEventListeneronly. - A listener's first run on Qwik. Qwik loads a listener's code when the listener first runs, and until then the listener runs after the event's dispatch, preloaded or not. On that run it reads the DOM after the event's default action (a
keydownlistener sees the field after the key changed it), findsclipboardDataanddataTransferempty, and its ownpreventDefault(),stopPropagation()andstopImmediatePropagation()do nothing; the listeners of one input run in the order their code arrives. Once each listener has run once, Qwik runs them as every target does. The browser tests check that state: the Qwik adapter loads every listener's code before each input. Of the first-run differences, only a control that a$function calls is reported, on the console (L13). Write a control that Qwik must honour on the first event where Qwik runs it at dispatch: at the top of the listener, alone or under a test of the event. Read what the event carries from the event (event.key, not the field's value after akeydown). A first read ofclipboardDataordataTransferhas no portable spelling on Qwik. - On Qwik, a handler that awaits what another event's handler does. A listener whose control runs under a test of the event is a
sync$handler and the listener's own$handler after it,onKeyDown$={[sync$(…), $(…)]}. Qwik's loader queues that$handler behind every handler of an earlier event that has not finished. So a handler that waits for a confirmation from the next click or key never resumes when both elements have such a control, and one that waits on a slow handler of an earlier event runs only once that ends. Resolve a confirmation from a listener with no conditional control, or do not await another listener inside a handler. - On Angular, a keyed list of strings or numbers replaced whole, whose values are its keys. Angular's development mode warns NG0956: its advice for a list of objects created again with the same content. Every target creates the new rows, as the keys ask.
- Reordering a keyed list while focus is inside it.
The differences the compiler can see are capabilities, reported at the first use: on Qwik, conditional-event-control (an error) and late-prop (a warning); on Vue, listbox; on Astro, interactivity and the capabilities that refine it.
The decisions behind this page are ADR-0045, ADR-0046, ADR-0047, ADR-0048, ADR-0049 and ADR-0050.