Skip to content

feat(eslint): ban dynamic imports in test hooks, and convert the last 33 sites - #3026

Merged
os-zhuang merged 1 commit into
mainfrom
feat/eslint-no-dynamic-import-in-hook
Jul 30, 2026
Merged

feat(eslint): ban dynamic imports in test hooks, and convert the last 33 sites#3026
os-zhuang merged 1 commit into
mainfrom
feat/eslint-no-dynamic-import-in-hook

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

The prose rule added in #3015 did not hold on its own — and there is hard evidence for that. Sweeping for beforeAll(async () => { await import(…) }) found 37 files, and every single one already carried a raised timeout, on an escalating ladder:

Raised to Files
15s 2
30s 33
60s 2

One of them even left the escalation as advice — // Increase timeout to 30 seconds for heavy renderer imports — and plugin-kanban blew its raised 15s anyway at 15021ms (#3010). A convention that gets re-broken 37 times is a lint rule, not a paragraph.

The rule

object-ui/no-dynamic-import-in-test-hook (error, scoped to test files). A module loaded inside beforeAll/beforeEach bills its cold Vite transform to hookTimeout, so the test passes or fails on machine load rather than on the code it covers. At module scope the same cost lands in the import phase, which no test or hook timeout applies to.

The message names the exact fix rather than just complaining:

Do not load a module inside 'beforeAll' — … Import it at module scope instead (import './x';) … Raising the timeout is not the fix: every one of the 36 files found this way already had a raised timeout, escalating 15s -> 30s -> 60s …

Kept narrow — two exemptions, both pinned in the RuleTester

Both are legitimate, and both are real code in this repo, so neither is left to judgement:

  • A lazy factoryregisterLazy('x', () => import('./x')). The hook installs a loader; it never runs the import. The hook frame resets inside a nested function, so this never reports. Without this the rule would flag the console's own plugin registration and be unusable.
  • A deliberate re-import against per-run module statevi.resetModules() / doMock / doUnmock / stubEnv / stubGlobal in the same hook. There the re-import is the point, and hoisting it would break the test rather than speed it up.

No autofixer, on purpose. The mechanical case is a one-line move, but the general case captures the module into a variable the tests read — hoisting that means introducing top-level await and reordering side effects, which is not a rewrite a fixer should make unattended.

Why the 33 conversions are in the same PR

An error ratchet has to lint clean today — that is the convention the repo's other three rules follow ("Error so a new violation fails CI; the existing sites were converted first"). A warn would have enforced nothing here, since this repo already tolerates hundreds of warnings. So the rule only actually gates if the backlog goes with it.

  • 27 were the canonical pure warm-up → fully mechanical.
  • 6 also register overrides that must run after the barrel, so only the import moved out. Those keep a hook — now sync and untimed — and the ordering still holds, because static imports are evaluated before any hook.

The side-effect import goes after the last existing import, which is the faithful equivalent of a hook that ran once every static import had already been evaluated. (I initially placed it after the first import and caught two bugs in review: one spliced into the middle of a multi-line import { … } from, and one reordered evaluation. Both fixed before anything was committed.)

Verification

  • Rule: 19 RuleTester cases (12 valid / 7 invalid). A deliberately planted violation fails lint as an error with the fix in the message.
  • Sweep: 33 violations → 0. The only remaining error-level findings are 3 pre-existing ones under e2e/, which CI's per-package turbo run lint does not cover; untouched here.
  • Tests: the 33 converted files pass (209 tests). Full suite 100% green — 733 files passed / 1 skipped, 8552 tests passed / 24 skipped.
  • Cost moved, not removed: the suite's cumulative timed portion dropped 243s → 127s, while wall clock is unchanged (~229s). The work went from timed windows into the import phase — which is exactly the intent, and worth stating plainly so the number isn't read as a speedup.

AGENTS.md §9 now points at the rule instead of only describing the convention, and documents both exemptions so the next agent doesn't fight the linter.

Test/lint infrastructure only — no shipped code changes, so no changeset.

🤖 Generated with Claude Code

… 33 sites

The prose rule added in #3015 did not hold on its own, and there is hard
evidence for that: sweeping for `beforeAll(async () => { await import(…) })`
found 37 files, and EVERY one already carried a raised timeout, on an escalating
ladder — 15s -> 30s -> 60s. One even left the escalation as advice
(`// Increase timeout to 30 seconds for heavy renderer imports`), and
plugin-kanban blew its raised 15s anyway at 15021ms (#3010). A convention that
gets re-broken 37 times is a lint rule.

Adds `object-ui/no-dynamic-import-in-test-hook` (error, scoped to test files): a
module loaded inside `beforeAll`/`beforeEach` bills its cold Vite transform to
`hookTimeout`, so the test passes or fails on machine load rather than on the
code it covers. Imported at module scope the same cost lands in the import
phase, which no test or hook timeout applies to.

Kept deliberately narrow, with both exemptions pinned in the RuleTester because
both are legitimate and both are real code here:

  - a lazy FACTORY — `registerLazy('x', () => import('./x'))` — the hook
    installs a loader, it never runs the import. The hook frame resets inside a
    nested function, so this never reports. (Without this the rule would flag
    the console's own plugin registration and be unusable.)
  - a deliberate re-import against per-run module state (`vi.resetModules`,
    `doMock`, `doUnmock`, `stubEnv`, `stubGlobal`) — there the re-import is the
    point, and hoisting it would break the test rather than speed it up.

No autofixer on purpose: the mechanical case is a one-line move, but the general
case captures the module into a variable the tests read, and hoisting that means
introducing top-level await and reordering side effects.

Because an `error` ratchet has to lint clean today (the convention the other
three rules follow), this also converts the 33 remaining sites — 30 in
packages/components, 3 in plugin-dashboard. 27 were the canonical pure warm-up.
The other 6 also register overrides that must run AFTER the barrel, so only the
import moved out; those keep a (now sync, now untimed) hook, and the ordering
still holds because static imports are evaluated before any hook. The
side-effect import goes after the last existing import — the faithful equivalent
of a hook that ran once every static import had been evaluated.

Verified:
- rule: 19 RuleTester cases (12 valid / 7 invalid); a fresh violation fails lint
  as an error with the exact fix in the message.
- repo sweep: 33 violations -> 0. The only remaining error-level lint findings
  are 3 pre-existing ones under `e2e/`, which CI's per-package `turbo run lint`
  does not cover; untouched here.
- tests: the 33 converted files pass (209 tests), and the full suite is 100%
  green — 733 files passed / 1 skipped, 8552 tests passed / 24 skipped.
- the suite's cumulative timed portion dropped 243s -> 127s. Wall clock is
  unchanged (~229s): the work did not disappear, it moved out of timed windows
  into the import phase, which is the point.

AGENTS.md §9 now points at the rule instead of only describing the convention,
including both exemptions so the next agent does not fight the linter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Jul 30, 2026 11:26am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 27.9 KB 350 KB
Entry file index-BmQjMAQn.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.20KB 2.97KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.12KB 3.41KB
auth (LoginForm.js) 17.86KB 5.29KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.43KB 2.09KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.25KB 1.01KB
auth (org-roles.js) 6.72KB 2.85KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 458.27KB 100.20KB
core (index.js) 2.16KB 0.78KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 134.67KB 34.24KB
fields (index.js) 222.07KB 54.35KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 25.17KB 5.80KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.45KB 10.67KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 6.84KB 2.42KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.90KB 12.35KB
plugin-charts (index.js) 60.52KB 17.11KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 111.59KB 28.74KB
plugin-designer (index.js) 210.56KB 42.56KB
plugin-detail (index.js) 216.65KB 53.02KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 104.83KB 25.30KB
plugin-gantt (index.js) 162.26KB 39.53KB
plugin-grid (index.js) 180.86KB 47.45KB
plugin-kanban (index.js) 47.82KB 13.18KB
plugin-list (index.js) 102.39KB 24.18KB
plugin-map (index.js) 16.80KB 5.24KB
plugin-markdown (index.js) 13.65KB 4.67KB
plugin-report (index.js) 40.32KB 10.53KB
plugin-timeline (index.js) 25.75KB 7.32KB
plugin-tree (index.js) 8.36KB 2.81KB
plugin-view (index.js) 85.91KB 20.99KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 3.47KB 1.54KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 0.77KB 0.41KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (index.js) 2.00KB 0.96KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.04KB 1.93KB
types (system-fields.js) 2.39KB 1.17KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 1.08KB 0.64KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang merged commit b234b4f into main Jul 30, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the feat/eslint-no-dynamic-import-in-hook branch July 30, 2026 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant