Skip to content

feat(plugin): experimental <script main> SFC block - #347

Draft
Huxpro wants to merge 4 commits into
mainfrom
claude/vue-lynx-issue-314-h3ixhl
Draft

feat(plugin): experimental <script main> SFC block#347
Huxpro wants to merge 4 commits into
mainfrom
claude/vue-lynx-issue-314-h3ixhl

Conversation

@Huxpro

@Huxpro Huxpro commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Experiment for #314 — a dedicated <script main> SFC block so a component's main-thread code lives in one place instead of per-function 'main thread' directives:

<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function incrementCount() { count.value++ }
</script>

<script main lang="ts">
import { runOnBackground } from 'vue-lynx'

const onTap = () => {
  runOnBackground(incrementCount)()  // captures setup bindings, as usual
}
</script>

How it works

The block is lowered by a pure SFC-text rewrite in a pre-vue-loader (enforce: 'pre' on .vue), keeping the whole existing worklet pipeline untouched:

  • Every top-level function in <script main> (declaration, const f = () => {}, const f = function () {}, including expression-body arrows) gets the 'main thread' directive injected as its first body statement.
  • The block's content is appended to <script setup> (or the block becomes the setup block if none exists), so worklets capture setup bindings by closure and the template sees the functions as ordinary setup bindings (:main-thread-bindtap="onTap").
  • From there, the existing SWC transforms do everything: BG layer emits { _wkltId } context objects, MT layer emits registerWorkletInternal() calls.

The subtle part is dual-layer hash consistency: vue-loader's experimentalInlineMatchResource bakes the remaining loader chain (pre-loaders included) into every ?vue&type= sub-request, so the connector, script, and template sub-modules all parse the same rewritten source on both layers — _wkltId content hashes stay in sync by construction. The rewrite is also idempotent (once lowered, no <script main> block remains).

Semantics

In <script main> Behavior
top-level functions become main thread functions (worklets)
imports merged into setup scope; exact-duplicate specifiers deduped
other const/let background-evaluated, captured by closure (use useMainThreadRef() for MT state)
top-level side effects, exports compile error
<script main setup>, two main blocks, lang mismatch, src compile error

Errors strip the block from the output (never letting vue-loader misparse it as an Options API script) and surface via emitError.

Docs

  • New dedicated guide page "The <script main> Block" (EN + ZH) covering syntax, semantics, a migration guide, style trade-offs, and limitations; added to the sidebar with an experimental tag.
  • The main-thread-script guide keeps a short teaser section linking to it; both tutorials (gallery, swiper) note that their embedded finished sources use the block style.

Example migrations (proof the style is usable)

Three existing MTS examples were migrated from directives to the block style, each exercising a distinct capability:

Example What it proves
main-thread/shared-module with { runtime: 'shared' } import called directly on the MT inside the block
swiper/SwiperMTS three touch handlers sharing MainThreadRef state across block functions
gallery/GalleryComplete worklet-calling-worklet (onScrollMTSadjustScrollbarMTS) + SystemInfo

Plus the new script-main-block entry in the main-thread example (a cross-thread-calls twin in the new syntax).

Verification

  • Unit tests (15): SFC rewrite behaviors, error cases, comment handling, idempotency, plus an end-to-end pass through real @vue/compiler-sfc + both SWC worklet transforms asserting the BG _wkltId equals the MT registration hash.
  • Compiled equivalence: each migrated example was built from both its directive-style original and the migrated source; the emitted registerWorkletInternal function bodies are identical after normalizing content-hash ids — behavior preserved by construction.
  • Lynx for Web harness (headless Chromium + @lynx-js/web-core, pixel-level assertions through the closed shadow DOM): shared-module tap cycles the box color #4FC3F7 → #81C784 → #FFB74D via the MT handler; gallery's MTS scrollbar thumb tracks the auto-scrolling list; script-main-block's MT tap → runOnBackground round trip re-renders the tap counter with pixel-exact parity against the directive-style cross-thread-calls twin. (The runOnMainThread color leg is a no-op in the current web-core environment for both styles — pre-existing upstream behavior, not a block regression. SwiperMTS can't be pixel-tested on web because its image/linear layout doesn't render in the harness even pre-migration; it's covered by the compiled-equivalence check.)
  • Full testing-library suite passes (238 tests); website builds and the docs link checker passes.

Notes / open questions

  • Syntax: went with <script main> as proposed in Feature request: native <script main> SFC block instead of per-function 'main thread' directive #314. <script main-thread> would be more explicit; easy to alias.
  • Non-function declarations: the issue's example writes const mainRef = ref() inside the main block expecting an MT ref. This implementation keeps such statements background-evaluated (consistent with today's capture semantics) and documents useMainThreadRef() instead — auto-rewriting ref() felt too magical for a first pass.
  • Editor tooling (Volar) doesn't understand the block yet; the types/volar-plugin could learn it in a follow-up.
  • Block scanning is regex-based (same tradeoff as vue-sfc-script-extractor): a </script> inside a string literal of the block isn't supported. HTML comments mentioning <script main> are handled.
  • Adds @babel/parser (+@babel/types, types-only) as a vue-lynx dependency for the lowering — already present in the tree via @vue/compiler-core.

Changeset included (vue-lynx minor, example packages patch).

Closes #314

🤖 Generated with Claude Code

https://claude.ai/code/session_01VGiiwQGSBEokk6ggrt45Fv

Group a component's main-thread code in a dedicated <script main> block
instead of marking every function with a 'main thread' directive.

The block is lowered by a pure SFC-text rewrite in a pre-vue-loader
(enforce: 'pre') on .vue files: every top-level function gets the
'main thread' directive injected and the block merges into
<script setup> (or becomes one). vue-loader's
experimentalInlineMatchResource bakes pre-loaders into every ?vue&type=
sub-request, so the connector, script, and template sub-modules all parse
the same rewritten source on both layers, keeping the SWC worklet _wkltId
content hashes in sync between the BG context objects and MT LEPUS
registrations. Downstream semantics (value capture, MainThreadRef,
runOnMainThread/runOnBackground, shared modules) are unchanged.

Lowering rules: top-level functions become worklets; imports merge into
the setup scope with exact-duplicate specifiers deduplicated; other
variable declarations stay background-evaluated (captured by closure);
top-level side effects and exports are compile errors; HTML comments
mentioning <script main> are ignored by the block scanner.

Includes unit tests (including an end-to-end compiler-sfc + SWC pass
asserting matching BG/MT worklet hashes), a script-main-block entry in
the main-thread example (verified: both worklets register with matching
ids in the built bundle), and EN/ZH guide docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGiiwQGSBEokk6ggrt45Fv
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vue-lynx Ready Ready Preview Jul 30, 2026 4:41pm

Request Review

Huxpro commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

CI note: the Lint job failure is pre-existing on the base branch, not from this diff — biome check . fails at main's HEAD on packages/vue-lynx/runtime/src/transition-shared.ts:146 (noExplicitAny; the biome-ignore comment sits one line above the parameter it targets, so it has no effect — introduced in #316). This PR touches neither that file nor the lint config, and biome is clean on all files it adds/changes.

#346 already carries the one-line fix (moving the suppression onto the parameter line). Once that lands on main, I'll merge the base into this branch to re-run CI. Will keep watching the remaining jobs on this run.


Generated by Claude Code

Add a dedicated guide page for the experimental <script main> syntax
style (EN/ZH, sidebar entries, cross-links from the main-thread-script
guide and both tutorials), and migrate three existing MTS examples from
per-function 'main thread' directives to the block style as living
proof it works:

  - main-thread/shared-module: MT tap handler calling a
    `with { runtime: 'shared' }` import directly on the main thread
  - swiper/SwiperMTS: three touch handlers sharing MainThreadRef state
  - gallery/GalleryComplete: worklet-calling-worklet MTS scrollbar

Verification (Lynx for Web harness, headless Chromium + web-core):
  - shared-module: tap cycles box color #4FC3F7 → #81C784 → #FFB74D via
    the MT handler (pixel-sampled)
  - gallery: the MTS scrollbar thumb tracks the auto-scrolling list
  - script-main-block: MT tap → runOnBackground round trip re-renders
    the tap counter, with pixel-exact behavior parity against the
    directive-style cross-thread-calls twin
  - all three migrations also compile to worklet bodies byte-identical
    to their directive-style originals (registrations diffed after
    normalizing content-hash ids), so behavior is preserved by
    construction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGiiwQGSBEokk6ggrt45Fv

Huxpro commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

CI is fully green now: #346's lint fix landed on main, and after merging the base into this branch (9ed7e6c) all six jobs pass — Lint included. The PR now also carries the follow-up work: a dedicated <script main> guide page (EN/ZH) and three MTS examples migrated to the block style (shared-module, SwiperMTS, GalleryComplete), verified behaviorally in a Lynx-for-Web harness and by compiled worklet-body equivalence against their directive-style originals. Details in the updated PR description. Ready for review.


Generated by Claude Code

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.

Feature request: native <script main> SFC block instead of per-function 'main thread' directive

2 participants