Skip to content

refactor: stop core/ importing the route tree, in all three apps, and lint it shut - #258

Merged
houko merged 11 commits into
mainfrom
p3/layering
Sep 19, 2026
Merged

houko merged 11 commits into
mainfrom
p3/layering

Conversation

@houko

@houko houko commented Sep 18, 2026

Copy link
Copy Markdown
Member

Description

core/ no longer imports upward into the route tree, in all three apps, and a lint rule keeps it that way.

The audit reported 441 upward imports as if they were 441 problems. They are four targets, and 95% of them are one 55-line Next.js router shim plus a static-asset directory that simply live in the wrong folder. So this is a handful of moves plus a large mechanical import rewrite — 629 import lines across ~620 files — not a redesign. rg 'from "@/app/' apps/web/core now returns 0.

What moved:

  • app/hooks/{navigation,link} → core/lib/navigation, in all three apps.
  • app/assets/ → core/assets/, in all three apps.
  • app/hooks/{image,script}.tsx deleted — zero importers anywhere in the repo, so they were never moves.
  • StarUsOnGitHubLink and admin's auth header out of the route tree.
  • common/empty-state.tsx colocated with the other empty states.
  • The 44 files that break the repo's kebab-case convention renamed, and unicorn/filename-case turned on.

Two guard rails, both verified to fire rather than assumed: no-restricted-imports on @/app/** from anything under core/, and unicorn/filename-case. Both are errors, not warnings, because apps/web's script allows 11,957 warnings and a warning there is invisible.

Type of Change

  • Code refactoring

Screenshots and Media (if applicable)

None, and one check is genuinely owed here — see below.

Test Scenarios

  • pnpm turbo run build check:types check:lint check:format --force — 59/59, no cache, exit 0.
  • The build is the load-bearing check, not check:types. tsc cannot verify asset paths: *.webp?url resolves through vite/client's ambient declaration, so a broken asset import type-checks fine and only a real build catches it. The build was run.
  • The lint rule was proved to fire, not assumed: a violating import was added under core/, oxlint reported eslint(no-restricted-imports) as an error, and the probe was removed.
  • rg 'from "@/app/' apps/web/core → 0 lines.

Still owed, and not done: a browser smoke check on the asset move — load the app once and confirm the favicon, the logo spinner and one empty-state illustration render. The build emits them and index.html links the hashed favicons, but no renderer has run.

References

The plan missed a real dependency and it would have silently broken things. packages/brand/generate.sh writes the favicons, apple-touch icon, PWA icons and the two auth gradients directly into apps/$app/app/assets/ for web, space and admin in one loop. Moving only apps/web as the plan said would have left the brand generator recreating apps/web/app/assets and orphaning the committed rasters. All three apps moved in one commit, with generate.sh repointed in it. Anyone regenerating brand rasters must be on this branch or later.

Other corrections, all measured:

  • The plan's claim that the volume is case-sensitive and ConfirmWorkspaceMemberRemove.tsx / confirm-workspace-member-remove.tsx are a case-duplicate pair is wrong on the premise — this is APFS, case-insensitive. The pair coexists because the names differ by hyphens, not only case. It is just a dead PascalCase copy that imports its Props type back from the live kebab file, and it is deleted here. The six -HOC → -hoc renames are case-only; git mv recorded them R100, but a reviewer applying the patch on a case-sensitive box should confirm.
  • The string-collision numbers for the rename step were understated: rg -F start_date is 744 lines, not 395; target_date 572, not 257; state_group 251, not 37. The plan's conclusion — rename by path, never by basename — is right and matters more than the numbers.
  • The three navigation shims are not unified: web defers push/replace/back/forward through setTimeout(..., 0) and space's and admin's call navigate() directly (admin's turned out byte-identical to space's). Extraction would be a behaviour decision, so each app keeps its own copy.
  • oxlint 1.51.0 rejects an unknown comment key inside an overrides[] entry, so the rationale for exempting apps/api/tools lives in the commit message rather than beside the override.
  • packages/types/src/issues/issue_subscription.ts is exported by no barrel and referenced nowhere. Renamed rather than deleted — deleting dead modules is chore: remove 328 unreferenced files — 22% of the tracked tree — after a skeptic pass rescued 62 of the candidates #252's job — but worth folding into a dead-code pass.

Not done, as scoped: steps 6, 7, 9 and 10 of the plan — merge core/components/core/theme into appearance/, rename core/components/ui to skeletons, extract the three app-shell primitives into core/components/shell, disperse core/components/core/modals. Three of those want #252 merged first.

Also left: apps/web/helpers/ is a fourth root alongside app/ and core/, mapped as @/helpers/*, and it imports assets and the link shim. It is the only structural oddity remaining in the path map.

Merge order: this branch and #257 (icons) both rewrite import lines across hundreds of the same files and will conflict heavily — one merges, the other rebases. #252 deletes 57 apps/web modules and 256 app/assets files, so it should go before both; this branch was built pre-#252 and its counts are correspondingly larger than the plan's post-#252 estimates.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

React Doctor found 3 new issues in 3 files · 3 warnings · score 66 / 100 (Needs work) · 2 fixed · vs main

3 warnings

core/components/dropdowns/estimate.tsx

  • ⚠️ L218 Interaction on static element no-static-element-interactions

core/components/issues/select/base.tsx

  • ⚠️ L153 Interaction on static element no-static-element-interactions

core/components/settings/profile/sidebar/item-categories.tsx

  • ⚠️ L61 Control missing accessible label control-has-associated-label

Reviewed by React Doctor for commit 1f28a8c. See inline comments for fixes.

houko and others added 11 commits September 19, 2026 22:09
These two files were part of the next/* compatibility layer added when the app moved from Next.js to React Router, and unlike the navigation and link shims next to them nothing ever imported them: `rg 'hooks/(image|script)'` over the whole tree returns nothing, there is no dynamic `import(` of any `@/app` path, and no tsconfig path maps `next/image` or `next/script` onto them. Removing them now means the router-shim move that follows relocates two files instead of four.
This was the only import in core/ that reached into app/ by URL segment: core/components/navigation/top-navigation-root.tsx imported `@/app/(all)/[workspaceSlug]/(projects)/star-us-link`, so renaming the `(projects)` route group would have broken a top-level navigation component for reasons a reader of either file could not see. The component is not a route — nothing in app/routes/ registers it — it is a link in the top nav, which is exactly what core/components/navigation/ holds, and the consumer now reaches it as a sibling `./star-us-link`.
core/ is the reusable layer and app/ is the React Router route tree, but 328 of core/'s import lines pointed upward at `@/app/hooks/navigation` and `@/app/hooks/link` — the two next/navigation and next/link compatibility shims left over from the Next.js port (370 lines once app/'s own imports and helpers/authentication.helper.tsx are counted, because the shims themselves move). Nothing about either file is route-specific: between them they import only react, react-router and `ensureTrailingSlash`, and they were parked under the route tree purely by accident of where the port started. They now live next to the rest of the framework glue in core/lib (store-context, wrappers, polyfills, idle-task), which means the route tree can be reorganised without touching a single file in core/.

No tsconfig or vite change is needed: `@/*` already maps to `./core/*`, so `@/lib/navigation` resolves the moment the files land there. Every one of the rewritten references was a static `from "…"` import declaration — there is no dynamic `import()` of any `@/app` path and no string registry — so this is a pure prefix substitution. `git diff -U0 -- '*.ts' '*.tsx' | rg '^[+-]' | rg -v '^(\+\+\+|---)' | rg -v '@/(app/hooks|lib/navigation)'` prints nothing, which is the mechanical proof that no line outside the substitution moved.

The shim keeps its `setTimeout(…, 0)` deferral on push/replace/back/forward. apps/space's and apps/admin's copies navigate directly instead, so the two are not interchangeable and each app keeps its own.
478 static files — 434 in web, 27 in space, 17 in admin — sat inside the React Router route directory while being imported 214 times, 166 of those from core/ or helpers/. Nothing about a cover image, an empty-state illustration or a favicon is route-specific, and keeping them under app/ meant the reusable layer could not render a picture without importing upward from the layer above it. `@/*` already maps to `./core/*` in all three tsconfigs, so `@/assets/…` resolves with no config change; the substitution is prefix-only, which is why the `?url` query suffixes come through untouched.

packages/brand/generate.sh writes the favicons, the apple-touch icon, the two PWA icons and the two auth gradients directly into these directories, so it is repointed in the same commit — otherwise the next brand regeneration would silently recreate the old app/assets tree and leave the committed rasters unreachable.

Verified with a real build, not just tsc: `*.png?url` resolves through vite/client's ambient module declaration, so tsc cannot tell a moved asset from a missing one. `turbo run build check:types check:lint check:format` is green across all 59 tasks, and web's build output lists the moved rasters (favicon-16x16, icon-512x512, og-image, maintenance-mode-*) as emitted assets.
…r out of the route tree

The same layering inversion apps/web had: space's and admin's next/navigation and next/link shims lived under app/hooks/, so 42 import lines — 25 of them in the reusable layers — pointed upward into the route tree, and admin's AuthHeader, rendered by core/components/instance/failure.tsx and setup-form.tsx as well as by the sign-in form, was addressed as `@/app/(all)/(home)/auth-header`, which encoded two route group names in an import path. AuthHeader now sits in core/components/common next to pace-lockup and page-wrapper, the pieces it is made of. After this commit no file in any of the three apps imports `@/app/…`.

Each app keeps its own copy of the shim rather than sharing one. web's defers push/replace/back/forward through `setTimeout(…, 0)` ("Defer navigation to avoid state updates during render") while space's and admin's navigate directly; those two are byte-identical to each other, but unifying all three would change navigation timing in one app, and the repo has no JavaScript test that would catch the regression. That is a behaviour decision, not part of a move.
The 480-odd upward imports the previous commits removed grew one file at a time, and nothing in the repo would have objected to the next one. This rule makes the layering a build error rather than a convention: any `@/app/**` import from a file under core/ fails check:lint.

Three details are load-bearing and were each confirmed by making the rule fire and then removing the violation. The group must be `@/app/**`, because oxlint's single `*` matches one path segment and `@/app/*` matches nothing we care about. The override glob must be `**/core/**` and not `apps/*/core/**`, because oxlint matches paths relative to the working directory and each app's check:lint runs with cwd set to the app — which also means the rule necessarily covers space and admin, so they had to be cleaned up first. And the severity must be `error`: apps/web's script is `oxlint --max-warnings=11957`, which would swallow a warning without a trace.
`EmptyState` lived in core/components/common while the three empty-state roots it sits beside conceptually — SimpleEmptyState, DetailedEmptyState, SectionEmptyState — live in core/components/empty-state, so a reader looking for "the empty state component" found three of four. This is colocation, not deduplication: the moved component takes a raw `image` plus button props and is not a variant of the other three, so it keeps its own file and its own name.
…tion

1596 of apps/web/core's 1620 files are kebab-case; the exceptions were 10 snake_case MobX stores, 3 snake_case components, 6 `-HOC.tsx` files, two `useXColumns.tsx` hooks, `workItem-detail.tsx` and one camelCase directory (gantt-chart/helpers/blockResizables), plus 3 files in apps/space/core, 3 in packages/editor and 15 in packages/types. Nothing chose those names on purpose — they are the residue of several import waves — and the cost of leaving them is that every new file is a coin flip. With the renames done the convention becomes machine-checkable, which is what the next commit does.

The renames are path-scoped, not string-scoped, and that distinction is the whole risk here: across apps and packages `rg -F start_date` hits 744 lines, `target_date` 572 and `state_group` 251, essentially all of them API payload keys and group-by values — `state_group` is a documented view property in packages/types. The actual module references are 1, 1 and 4. Every one of the 93 rewritten lines matched a quoted specifier whose final path segment was the old basename (a `/` immediately before it), so no payload key or translation key could be caught by it.

apps/web/core/components/workspace/ConfirmWorkspaceMemberRemove.tsx is deleted rather than renamed: confirm-workspace-member-remove.tsx already exists, is the version both call sites import, and exports the `Props` type that the PascalCase copy imports back from it. There is no name left for it to take.
`unicorn/filename-case` was explicitly `"off"`, which is why the 44 files the previous commit renamed accumulated in the first place. It is on at `error` now, so the convention is checked rather than remembered; apps/web's `--max-warnings=11957` would have swallowed a warning.

apps/api/tools is exempt. Its ten `generate_*.mjs` files are Go-side generators named to match the testdata they emit, and apps/api/README.md documents `tools/generate_ydoc_schema.mjs` by that name, so renaming them would break a documented interface to buy nothing.
…, shell, modals (#260)

* refactor(web): merge core/components/core/theme into core/components/appearance

Theme lived in two directories: core/components/appearance held the public entry point (theme-switcher.tsx, the only file the barrel re-exports and the only one settings reaches) while the six pieces it composes sat under the unnamed core/components/core bucket. A reader arriving at the switcher had to leave the feature directory to find the selector it renders.

appearance/ is the home rather than a new top-level theme/ because it is feature-named and already owns the entry point. The four files reached only through relative ./ imports from custom-theme-selector.tsx move with it untouched, so the entire cross-directory cost is the two specifiers in theme-switcher.tsx, which become sibling-relative in line with how the rest of this directory already refers to its own files.

* refactor(web): move the two non-loaders out of core/components/ui

core/components/ui holds 24 files, 22 of which are skeleton loaders under loader/. The two exceptions are a label-count chip and an empty-state layout, and both have an obvious owning directory already: IssueLabelsList belongs with the other label components and EmptySpace/EmptySpaceItem with the other empty states. Moving them first means the directory that gets renamed next contains nothing but loaders, so the new name needs no qualification.

Neither file is added to core/components/labels/index.ts: that barrel is hand-maintained and already omits several of its neighbours, so the single consumer keeps importing by path.

* refactor(web): rename core/components/ui to core/components/skeletons

The path lied twice over. apps/web imports the @pace/ui package on nearly every screen, so "@/components/ui" reads as a local copy of the design system when in fact every file under it is a loading skeleton. Naming the directory for what it contains removes the collision and tells a reader where skeletons live without opening the folder.

Four files stay behind in core/components/ui because #252 (chore/remove-dead-code-and-assets) deletes them: markdown-to-component.tsx, profile-empty-state.tsx, loader/notification-loader.tsx and loader/pages-loader.tsx. All four are unreferenced anywhere in the repo, and none of them imports a sibling, so leaving them in place breaks nothing and keeps this rename from colliding with that deletion. core/components/ui disappears when #252 lands.

* refactor(web): extract the route-shell primitives into core/components/shell

PageTitle, ContentWrapper and AppHeader are the three highest-traffic residents of core/components/core, and unlike the rest of that bucket they share an obvious identity: they are the chrome every route renders around its content, consumed almost entirely by app/ layouts and pages. Ninety-eight of the directory's import lines point at these three files, so naming them is where the bucket's cost actually is.

shell rather than app-shell keeps the import short, and rather than core/layouts because that directory already exists with a different meaning: it holds three route-level layout components, not primitives those layouts compose.

The remaining 33 files in core/components/core are deliberately untouched. They have no single destination between them and each one wants a judgement call about its owning feature; emptying the directory is not what this change is for.

* refactor(web): disperse core/components/core/modals into the owning features

Every one of the eight files in this directory has an unambiguous owner, so the grouping by "is a modal" bought nothing: it only guaranteed that anyone working on work items, the workspace logo or the profile avatar had to look somewhere other than the feature directory. Fifteen import lines total, and the two relative imports inside the group (bulk-delete-issues-modal to its item, existing-issues-list-modal to its empty state) survive untouched because both pairs land in issues/.

The two destinations that needed a judgement call: gpt-assistant-popover.tsx goes to editor/ rather than issues/ because it is a generic AI-completion popover over a RichTextEditor with no work-item concept in it, and its sole consumer imports it as an editor affordance. user-image-upload-modal.tsx goes to profile/ rather than account/ because it edits the profile avatar; account/ in this codebase holds authentication and account-lifecycle concerns, which is also why change-email-modal.tsx goes there.

core/modals/ (the unrelated top-level directory) is not involved, and the remaining 25 files of core/components/core are left alone.

* chore: bump the workspace version, which check-version requires of every PR
@houko
houko merged commit 04916ad into main Sep 19, 2026
15 of 16 checks passed
@houko
houko deleted the p3/layering branch September 19, 2026 13:27
houko added a commit that referenced this pull request Sep 19, 2026
…r a skeptic pass rescued 62 of the candidates (#252)

Rebased onto main after the other seven branches in this batch landed. The deletion list was re-verified against the current tree rather than carried over, because #257 and #258 moved a great deal of it.

Eighteen files that main had edited (icon-path and router-shim rewrites) were deleted by this branch. Each was re-checked for consumers in the post-merge tree before the deletion was accepted; none had any. The same check was run over all 256 assets, again finding no references — #258 moved them into `core/assets` and updated their importers, so an asset that had a consumer would have shown one.

`root.store.ts` needed both sides: main's kebab-case rename of `cycle_filter.store`, and this branch's removal of `DashboardStore`.

`apps/web/package.json` had each side dropping a different orphaned dependency — main dropped `react-is`, this branch dropped `react-markdown` — so the merge drops both. Neither is imported anywhere. `pnpm-lock.yaml` was regenerated from the resolved manifests rather than merged by hand.

Verified: typecheck, lint, format and build across all 56 workspace tasks, plus `go build` and the full Go test suite.
houko added a commit that referenced this pull request Sep 19, 2026
`TestAChangeOnOneServerReachesTheOther` fails on CI roughly one run in eight, always as `0 sync frame(s) arrived in ten seconds`. It hit #254 and #258 during the P3 batch; neither touches `internal/live`, and #258 does not touch Go at all.

`Relay.Subscribe` records a subscription in `r.subscriptions` and only afterwards waits for Redis to confirm it, because `client.Subscribe` merely queues the command — `Receive` is what sends it. `waitForSubscription` polled that map, so it returned during the window between those two statements. Pub/sub keeps no backlog, so the change published in that window is not delayed, it is dropped, and the test waits out its full ten seconds for a frame that no longer exists.

The relay itself is unchanged: its own ordering is correct, it confirms before it publishes. The test now asks Redis with `PUBSUB NUMSUB`, which only counts a subscriber once the `SUBSCRIBE` has landed. Both servers share one Redis, so one question covers the pair.

`0 frames` is what points here rather than at a slow machine: every server publishes SyncStep1 and QueryAwareness from `Subscribe` itself, so a server that were subscribed would have received those too. Receiving nothing means it was not subscribed when the other side published.

Verified: the `internal/live` suite passes, including under CPU contention and `-race`. Not verified: the original failure did not reproduce locally in 60 runs under load, on either the old or the new code. This closes a window that is certain from reading the code, not one demonstrated by a local repro. If the test flakes again after this, the root cause is elsewhere and this reasoning should be discarded rather than patched over.
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.

1 participant