Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 11 additions & 5 deletions assets/src/core/stimulus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ interface ResolvedController {
// which is why a stray `stimulusFetch: 'lazy'` sitting above the imports (or anywhere else) does
// NOT flip the controller to lazy. It may be a block comment or a single-line one, single or
// double quotes; a block comment may sit on the class's own line, a line comment must precede it.
// A preserved block comment (`/*! ... */`, the form tsc/esbuild keep so the marker survives
// minification) is recognised too.
const LAZY_COMMENT_RE =
/(?:\/\*\s*stimulusFetch:\s*['"]lazy['"]\s*\*\/|\/\/\s*stimulusFetch:\s*['"]lazy['"])\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\b/i;
/(?:\/\*!?\s*stimulusFetch:\s*['"]lazy['"]\s*\*\/|\/\/\s*stimulusFetch:\s*['"]lazy['"])\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\b/i;
const LOCAL_CONTROLLER_RE = /[-_]controller\.[jt]s$/;

export function generateControllersModule(opts: ResolvedStimulusOptions, root: string, isDev: boolean): string {
Expand Down Expand Up @@ -173,10 +175,14 @@ function listLocalControllers(dir: string): string[] {
} catch {
return []; // dir absent -> no local controllers
}
return entries
.map((e) => String(e).replace(/\\/g, '/'))
.filter((e) => LOCAL_CONTROLLER_RE.test(e))
.sort();
return (
entries
.map((e) => String(e).replace(/\\/g, '/'))
.filter((e) => LOCAL_CONTROLLER_RE.test(e))
// Sort so the generated module -- and therefore its content hash -- stays stable
// regardless of the filesystem's iteration order. See symfony/ux#3703.
.sort()
);
}

function localIdentifier(rel: string): string {
Expand Down
27 changes: 27 additions & 0 deletions assets/test/core/stimulus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ describe('generateControllersModule — local', () => {
expect(src).toContain(posix(join(root, 'controllers/single_line_controller.js')));
});

it('detects the lazy marker inside a preserved /*! ... */ comment', () => {
const src = generateControllersModule(localOpts, root, false);
// tsc/esbuild keep `/*! ... */` comments through minification, so the marker survives.
expect(src).toContain(`"preserved-comment": () => import(`);
expect(src).toContain(posix(join(root, 'controllers/preserved_comment_controller.js')));
});

it('ignores a lazy marker that sits above the imports (it must be directly above the class)', () => {
const src = generateControllersModule(localOpts, root, false);
// The marker in `above_imports_controller.js` precedes the imports, not the class,
Expand All @@ -78,6 +85,26 @@ describe('generateControllersModule — local', () => {
expect(src).toContain('export const eagerControllers = {}');
expect(src).toContain('export const lazyControllers = {}');
});

it('emits controllers in a stable, name-sorted order (deterministic output/hash)', () => {
// Local controllers are sorted by filename so the generated module -- and thus its
// content hash -- stays stable regardless of the order the filesystem returns the files
// in (symfony/ux#3703). Assert the exact identifier order the module is rendered in.
const src = generateControllersModule(localOpts, root, false);
const identifiers = [...src.matchAll(/^ {2}"([^"]+)":/gm)].map((m) => m[1]);
expect(identifiers).toEqual([
// eagerControllers: third-party first, then local controllers sorted by filename
'acme--ux-hello--hello',
'above-imports',
'admin--user',
'greet',
// lazyControllers: third-party first, then local controllers sorted by filename
'acme--ux-map--map',
'heavy',
'preserved-comment',
'single-line',
]);
});
});

describe('generateControllersModule — identifier collision', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/*! stimulusFetch: 'lazy' */
export default class {}
4 changes: 3 additions & 1 deletion doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ imports) — a block or a single-line comment both work:
/* stimulusFetch: 'lazy' */
export default class extends Controller {}

(``// stimulusFetch: 'lazy'`` on the line above the class works too.)
(``// stimulusFetch: 'lazy'`` on the line above the class works too, as does a
preserved ``/*! stimulusFetch: 'lazy' */`` comment — the form tsc and esbuild keep
through minification.)

**Third-party UX packages.** Controllers declared in ``controllers.json`` are
resolved from ``node_modules``, so install them with your package manager, the
Expand Down
Loading