Skip to content

Folio::Tiptap::Model#tiptap_config vrací sdílený mutovatelný singleton #640

Description

@VladaTrefil

Kontext

Folio::Tiptap.config (app/lib/folio/tiptap.rb:15) je memoizovaný singleton na úrovni modulu:

def self.config
  @config ||= Folio::Tiptap::Config.new
end

Folio::Tiptap::Model#tiptap_config (app/models/concerns/folio/tiptap/model.rb:182) vrací přesně tuto jednu instanci každému modelu, který si metodu nepřepíše:

def tiptap_config(attribute_name: nil)
  Folio::Tiptap.config
end

Problém

Všechny modely bez vlastního tiptap_config tedy sdílejí jednu společnou mutovatelnou instanci Config. To má dva nepříjemné důsledky:

  1. Únik mutací mezi modely. Pokud jakýkoli kód změní vrácený objekt na místě (config.node_names << ..., node_names.delete, schema= apod.), změna se projeví u všech modelů bez přepisu a přetrvá po celou dobu běhu procesu. Jde o tichou chybu — nikde se neprojeví výjimkou, jen se „nečekaně“ objeví/zmizí node v editoru jiného modelu.
  2. Modely bez přepisu dědí kompletní auto-discovered sadu nodů. Config.new bez node_names spadne do get_all_tiptap_node_names, což přes glob app/models/**/tiptap/node/**/*.rb načte úplně všechny nody (v naší aplikaci včetně Paywall::Delimiter).

Tohle už nás v aplikaci smilemusic stálo celou ladicí session (FUL-198): Paywall::Delimiter se objevoval v editorech MagazineIssue a Author, protože ty si tiptap_config nepřepisovaly a dostávaly tak globální sadu. Vyřešili jsme to na straně aplikace (přepis na curated Publisher.default_tiptap_config), ale samotný footgun ve foliu zůstává — příští model bez přepisu, případně první kód, který singleton zmutuje na místě, narazí na totéž.

Reprodukce

g  = Folio::Tiptap.config
m1 = ModelBezPrepisu.new.tiptap_config
m1.object_id == g.object_id   # => true (stejný objekt)

# Mutace u jednoho modelu prosákne ke všem ostatním:
m1.node_names << "Foo"
ModelBezPrepisu2.new.tiptap_config.node_names.include?("Foo")  # => true

Návrhy řešení

Návrh A — dup při čtení (zpětně kompatibilní, nízké riziko)

tiptap_config (a/nebo Folio::Tiptap.config) vrací kopii s hluboce zkopírovanými mutovatelnými vnitřnostmi (node_names, node_groups, schema):

def tiptap_config(attribute_name: nil)
  Folio::Tiptap.config.dup   # + Config#initialize_copy, který kopíruje node_names atd.
end
  • ✅ Stávající vzor config.node_names << ... funguje dál beze změny.
  • ✅ Mutace už neuniká mezi modely.
  • ⚠️ dup musí být dostatečně hluboký (mělký dup sdílí pole) — je potřeba initialize_copy.
  • ⚠️ Drobná režie na každé volání.

Návrh B — zmrazit singleton (hlasité selhání, drobně breaking)

Singleton i jeho node_names zmrazíme; jakákoli mutace na místě vyhodí FrozenError:

def self.config
  @config ||= Folio::Tiptap::Config.new.freeze   # + freeze node_names
end
  • ✅ Tichou chybu mění na okamžitou, jasnou výjimku.
  • ✅ Levné, žádná režie.
  • ⚠️ Breaking pro kód, který dnes singleton mutuje (po našem auditu už to v smilemusic nikdo nedělá, ale jiné aplikace na foliu mohou). Nutí to k explicitní konfiguraci.

Návrh C — immutable Config + kompoziční API (nejčistší, větší refactor)

Config se stane immutable a doplní se neměnící skládání:

config = Folio::Tiptap.config.with(node_names: config.node_names + %w[Foo])
# nebo Folio::Tiptap.build_config(extra_nodes: [...])
  • ✅ Architektonicky nejčistší, mutace jako vzor zcela mizí.
  • ✅ Dá se zkombinovat s B (zmrazený základ + #with).
  • ⚠️ Vyžaduje úpravu všech volajících (v aplikacích config.node_names << ...config.with(...)).

Doporučení

Krátkodobě Návrh A (dup) — odstraní únik, nevyžaduje změny v aplikacích a dá se vydat hned. Dlouhodobě směřovat k B + C (zmrazený základ + kompoziční #with), aby celá třída těchto chyb selhávala hlasitě místo tichého prosakování. Oprava patří do folia, protože chrání všechny aplikace, ne jen smilemusic.

Samostatná, ale související otázka k rozmyšlení: má být výchozí konfigurace pro modely bez přepisu skutečně „všechny auto-discovered nody“, nebo by default měl být spíš prázdný/konzervativní a sady se měly přihlašovat explicitně?

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions