Reprise 1.3.0 is out, with modulepreload under a strict CSP, a Stimulus dev loop without restarts, and safer writes of the metadata files. This is also the first release post since Reprise 1.0, so it covers three earlier changes that only got a changelog line: metadataPath from 1.2.0, style entries from 1.1.3, and Vite's top-level input in dev from 1.1.0.

Hugo Alliaume
Contributed by Hugo Alliaume in #134 and #149

Under a nonce-based Content-Security-Policy (script-src 'nonce-...', the kind NelmioSecurityBundle sets up), the <link rel="modulepreload"> tags Reprise renders, and their matching HTTP Link: preload headers, never carried a nonce. The browser blocks them, and once a modulepreload is blocked the module it points to fails to load, so the app's JavaScript never runs.

A nonce passed to reprise_entry_script_tags() through attributes, or set in the global script_attributes, now reaches the entry's modulepreload links and their Link: header, while the other attributes stay on the script tag:

1
2
3
4
{{ reprise_entry_script_tags('app', attributes={
    nonce: csp_nonce('script'),
    'data-turbo-track': 'reload',
}) }}
1
2
<link rel="modulepreload" href="/build/shared.js" nonce="r4nd0m">
<script src="/build/app.js" type="module" nonce="r4nd0m" data-turbo-track="reload"></script>

RenderAssetTagEvent is now dispatched for modulepreload links too, under a new modulepreload type ($event->isModulepreload()). And the Link: header now copies nonce, integrity and crossorigin from the final tag, after the listeners ran, instead of recomputing them from entrypoints.json: a listener changing them used to leave the header out of sync with the tag, and the browser discards a preload that does not match.

Stimulus Controllers Without a Dev Server Restart

Hugo Alliaume
Contributed by Hugo Alliaume in #138 and #143

The virtual:symfony/controllers module was generated once, when the dev server started. Adding or removing a local controller, toggling a stimulusFetch: 'lazy' comment, or a controllers.json updated by Symfony Flex after a composer require symfony/ux-* all needed a dev server restart to show up. Webpack Encore picked these changes up on its own.

Both Vite and Rsbuild now regenerate the module and reload the page when one of those files changes. It is a full page reload, not hot replacement of the controller. Local controllers can also use the .jsx and .tsx extensions, like they could with Encore:

1
2
3
assets/controllers/hello_controller.js        -> hello
assets/controllers/chart_controller.tsx       -> chart
assets/controllers/admin/user_controller.jsx  -> admin--user

No More Rebuild Loop Under Rsbuild

Hugo Alliaume
Contributed by Hugo Alliaume in #139 and #140

With @rsbuild/plugin-tailwindcss (Tailwind CSS v4 watches the project's directories, the one holding outputPath included), rsbuild dev and rsbuild build --watch rebuilt forever: after every compile, Reprise rewrites entrypoints.json, manifest.json and, in dev, the copied files, the watcher sees the directory change, and Rspack compiles again. Vite was not affected, since its watcher already ignores build.outDir. The Rsbuild integration now adds outputPath and the metadata files to Rspack's watchOptions.ignored, keeping Rspack's default ignores when you set none and merging with yours (RegExp, glob or function) when you do.

Those rewrites also raced with PHP. writeFileSync() truncates a file before writing it, so a request landing in between could read an empty entrypoints.json and fail with a JsonException. On both bundlers, the two files are now written to a temporary file next to them and renamed over the target, with a fallback to a direct write when the rename fails (Windows can refuse it while another process holds the file open).

Keep the Metadata Out of the Public Directory

Indra Gunawan
Contributed by Indra Gunawan in #121

Shipped in 1.2.0. Reprise wrote entrypoints.json and manifest.json into outputPath, next to the compiled assets. Upload that directory to public object storage or a CDN and the two files go with it, unless you exclude them by hand.

The new metadataPath option (defaulting to outputPath) writes them somewhere else, while the compiled assets and copied files stay in outputPath. AssetMapper gets the same idea in Symfony 8.2 with its new metadata_dir option.

1
2
3
4
5
6
7
8
9
10
11
12
// vite.config.ts
import { defineConfig } from 'vite'
import Symfony from '@symfony/reprise/vite'

export default defineConfig({
  plugins: [
    Symfony({
      outputPath: 'public/build',
      metadataPath: 'var/reprise',
    }),
  ],
})
1
2
3
4
5
6
7
8
9
10
11
12
// rsbuild.config.ts
import { defineConfig } from '@rsbuild/core'
import Symfony from '@symfony/reprise/rsbuild'

export default defineConfig({
  plugins: [
    Symfony({
      outputPath: 'public/build',
      metadataPath: 'var/reprise',
    }),
  ],
})
1
2
3
# config/packages/reprise.yaml
reprise:
    output_path: '%kernel.project_dir%/var/reprise'

If you rely on manifest.json for asset() calls, point framework.assets.json_manifest_path at the new location too, as the metadataPath documentation shows.

Style Entries Ship CSS Only

Guillaume Sainthillier
Contributed by Guillaume Sainthillier in #111

Shipped in 1.1.3. A style entry is an entry that points straight at a stylesheet, what Encore's addStyleEntry() did. Under Vite, it compiled to an empty JavaScript chunk that Vite pruned after Reprise had recorded it: the rendered <script> returned a 404, manifest.json gained a dead build/theme.js key, and with integrity enabled the build failed outright. Under Rsbuild, the entry advertised a runtime-only JavaScript file, which meant a pointless <script> and the same dead key.

A style entry now builds to CSS only on both bundlers, with no JavaScript file in entrypoints.json or manifest.json. On Rsbuild, the runtime-only file is also deleted from the build, the way Encore's DeleteUnusedEntriesJSPlugin did:

1
2
3
4
5
6
7
8
9
10
// vite.config.ts
export default defineConfig({
  input: {
    app: './assets/app.js',
    theme: './assets/styles/theme.scss',
  },
  plugins: [
    Symfony(),
  ],
})
1
2
3
4
5
6
7
8
9
10
11
12
13
// rsbuild.config.ts
export default defineConfig({
  source: {
    entry: {
      app: './assets/app.js',
      theme: './assets/styles/theme.scss',
    },
  },
  plugins: [
    pluginSass(),
    Symfony(),
  ],
})

reprise_entry_link_tags('theme') renders the <link>, and reprise_entry_script_tags('theme') has nothing to render. Reprise treats an entry as a style entry by its file extension, as the style entries documentation lists.

Vite's Top-Level Input in Dev

Antonio Pauletich
Contributed by Antonio Pauletich in #95

Shipped in 1.1.0. Vite 8.2 added a top-level input option, which Vite recommends over build.rolldownOptions.input because dev uses it too. Reprise's dev collector only read build.rolldownOptions.input and build.rollupOptions.input, so a project on the new option built fine, but its dev entrypoints.json had no entries.

The dev collector now falls back to the top-level input after the nested ones, the same precedence vite build applies, so dev and build name the same entries:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vite.config.ts
import { defineConfig } from 'vite'
import Symfony from '@symfony/reprise/vite'

export default defineConfig({
  input: {
    app: './assets/app.js',
  },
  plugins: [
    Symfony({
      // options
    }),
  ],
})

On Vite 8.1 and older, entries stay in build.rollupOptions.input. Rsbuild is unaffected: its entries live in source.entry.

Full Changelog

  • #134 [TagRenderer] Let CSP nonces reach modulepreload links and their Link headers (@Kocal)
  • #135 [Rsbuild] Treat build --watch as a build, not as dev (@Kocal)
  • #136 [Options] Guarantee a trailing slash on publicPath and manifestKeyPrefix (@Kocal)
  • #137 [Vite] Preload chunks an entry reaches through another chunk (@Kocal)
  • #138 [Stimulus] Pick up controller changes in dev without a restart (@Kocal)
  • #139 [Rsbuild] Stop the rebuild loop when a tool watches the output directory (@Kocal)
  • #141 [Cache] Do not cache a missing entrypoints.json (@Kocal)
  • #144 [Tests] Backdate every fixture path in the watch-loop test (@Kocal)
  • #143 [Stimulus] Register .jsx and .tsx local controllers (@Kocal)
  • #142 [Rsbuild] Honour devServerOrigin (@Kocal)
  • #140 [Emit] Write entrypoints.json and manifest.json atomically (@Kocal)
  • #145 [DevServer] Advertise a reachable origin for wildcard and IPv6 hosts (@Kocal)
  • #147 [Format] Drop joinUrl (@Kocal)
  • #146 [Vite] Drop the ViteOutputChunk type (@Kocal)
  • #151 [CI] Test Symfony 8.1 instead of 8.0 (@Kocal)
  • #148 [Repo] Remove leftovers and stale configuration (@Kocal)
  • #150 [Tests] Share integration test helpers and clean up temp dirs (@Kocal)
  • #152 [Emit] Write the Symfony files through a single pipeline (@Kocal)
  • #149 [TagRenderer] Make the preload header follow the final tag (@Kocal)
  • #153 [Vite][Rsbuild] Move each bundler's hooks into its own module (@Kocal)
  • #154 [Rsbuild] Ask Rspack only for the stats fields the manifest needs (@Kocal)

Reprise follows Symfony's backward compatibility promise since 1.0, and three of the changes above came from contributors outside the maintainer. Keep the feedback coming: issues and pull requests are welcome on GitHub.

Published in #Releases